Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and

### Changed

- **Test credentials are generated at run time.** The one literal API key in the tests is now
generated with `random_bytes()`, since secret scanning reported such literals as leaks, and a
guard test fails on any literal left behind. The guard is a minimal local copy of
`laranail/package-tools`' check: package-tools requires PHP `^8.4.1`, and this package still
supports 8.3, so it is not added as a dependency.
- `require` declares what `src/` uses directly: `illuminate/filesystem` and `illuminate/log`
(the `File` and `Log` facades in `FileChannel` and `LogChannel`), and `laravel/framework`, for
`Illuminate\Foundation\Bus\Dispatchable` and the `config_path()`/`now()` helpers, none of which
Expand Down
135 changes: 135 additions & 0 deletions tests/Unit/NoLiteralCredentialsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\Notifications\Tests\Unit;

use RecursiveIteratorIterator;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;

/**
* Keeps literal credentials out of the tests, so secret scanning has nothing to report.
*
* A secret scanner cannot tell a test key from a leaked one and reports both: any literal under a
* name such as `password`, `secret`, `token` or `api_key`, and the password in a URL. Tests
* generate such values at run time (`bin2hex(random_bytes(16))`) instead.
*
* A minimal local copy of laranail/package-tools' AssertsNoLiteralCredentials. This package
* supports PHP 8.3, and package-tools requires ^8.4.1, so depending on it, even in require-dev,
* would break the 8.3 CI leg. Mark a deliberate fixed value on its line, or the line above, with
* `// @credential-literal-exempt: <reason>`.
*/
final class NoLiteralCredentialsTest extends TestCase
{
private const string EXEMPTION = '@credential-literal-exempt';

public function test_the_tests_hold_no_literal_credentials(): void
{
[$scanned, $offenders] = self::scan(dirname(__DIR__));

// A floor, so a path that stops matching fails instead of reporting a clean suite.
self::assertGreaterThanOrEqual(8, $scanned, "Only {$scanned} PHP file(s) were scanned.");
self::assertSame([], $offenders, "These tests hold a literal credential:\n " . implode("\n ", $offenders));
}

public function test_the_scan_recognises_a_literal(): void
{
$name = 'api' . '_key';

self::assertCount(1, self::literalsIn("<?php ['{$name}' => 'abcd1234'];"));
self::assertCount(1, self::literalsIn(sprintf('<?php $x = "https://u:%s@example.test";', 'abcd1234')));
self::assertSame([], self::literalsIn("<?php ['{$name}' => \$generated];"));
}

/**
* @return array{int, list<string>}
*/
private static function scan(string $dir): array
{
$scanned = 0;
$offenders = [];

foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
if ($file->getExtension() !== 'php') {
continue;
}

$scanned++;
$source = (string) file_get_contents($file->getPathname());
$lines = explode("\n", $source);

foreach (self::literalsIn($source) as [$offset, $value]) {
$line = substr_count($source, "\n", 0, $offset) + 1;

if (str_contains($lines[$line - 1], self::EXEMPTION) || str_contains($lines[$line - 2] ?? '', self::EXEMPTION)) {
continue;
}

// Never the value itself: the message lands in CI logs.
$offenders[] = sprintf('%s:%d %s…', $file->getPathname(), $line, substr($value, 0, 2));
}
}

return [$scanned, $offenders];
}

/**
* @return list<array{int, string}> [byte offset, literal]
*/
private static function literalsIn(string $source): array
{
$name = '(?<name>[\w.-]*(?:password|passwd|secret|token|api_?key|private_?key)[\w.-]*)';
$literal = '\'(?<value>(?:[^\'\\\\]|\\\\.)*)\'';

$patterns = [
'/[\'"]' . $name . '[\'"]\s*=>\s*' . $literal . '/i',
'/\$(?:this->)?' . $name . '\s*=\s*' . $literal . '/i',
'/\bconst\s+(?:\??\w+\s+)?' . $name . '\s*=\s*' . $literal . '/i',
'/[(,]\s*' . $name . '\s*:\s*' . $literal . '/i',
'/(?:->|::)(?:set|assertSet)\(\s*[\'"]' . $name . '[\'"]\s*,\s*' . $literal . '/i',
];

$found = [];

foreach ($patterns as $pattern) {
preg_match_all($pattern, $source, $matches, PREG_OFFSET_CAPTURE);

foreach ($matches['value'] as $i => [$value, $offset]) {
if (self::isCredentialShaped($value) && ! self::namesMetadata($matches['name'][$i][0])) {
$found[] = [$offset, $value];
}
}
}

preg_match_all('#\b[a-z][a-z0-9+.-]*://[^\s:/@\'"]+:([^\s@/\'"$%{]+)@#i', $source, $urls, PREG_OFFSET_CAPTURE);

foreach ($urls[1] as [$value, $offset]) {
$found[] = [$offset, $value];
}

return $found;
}

/**
* Not a credential: short placeholders, prose, validation rules, masks, and a package name or
* path (`illuminate/auth`).
*/
private static function isCredentialShaped(string $value): bool
{
return strlen($value) >= 4
&& preg_match('/[A-Za-z0-9]/', $value) === 1
&& preg_match('/[\s|]/', $value) === 0
&& preg_match('#^[\w.-]+/[\w./-]+$#', $value) === 0;
}

/**
* A name that holds something about a credential rather than the credential: `token_type`,
* `password_field`, `api_key_header`.
*/
private static function namesMetadata(string $name): bool
{
return strcasecmp($name, 'passwords') === 0
|| preg_match('/[_.-](?:type|name|field|column|header|driver|table|broker|length|ttl|lifetime|expires?|url|uri|route|prefix|label|format|algo|algorithm|cost|rounds|rules?|message|mode|path|endpoint|placeholder)$/i', $name) === 1;
}
}
8 changes: 5 additions & 3 deletions tests/Unit/OutboundHttpChannelsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,14 @@ public function test_push_success_does_not_leak_api_key_in_body(): void
{
Http::fake(['onesignal.com/*' => Http::response(['id' => 'x'], 200)]);

$channel = new PushChannel(['api_key' => 'super-secret', 'app_id' => 'app-1']);
// Generated per run: a literal API key is reported by secret scanning as a leak.
$apiKey = bin2hex(random_bytes(16));
$channel = new PushChannel(['api_key' => $apiKey, 'app_id' => 'app-1']);

$this->assertTrue($channel->send($this->message('push body')));

Http::assertSent(function ($request) {
$bodyHasSecret = str_contains((string) $request->body(), 'super-secret');
Http::assertSent(function ($request) use ($apiKey) {
$bodyHasSecret = str_contains((string) $request->body(), $apiKey);

return $request['app_id'] === 'app-1' && ! $bodyHasSecret;
});
Expand Down
Loading