diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ffd320..712b295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/tests/Unit/NoLiteralCredentialsTest.php b/tests/Unit/NoLiteralCredentialsTest.php new file mode 100644 index 0000000..35ecf61 --- /dev/null +++ b/tests/Unit/NoLiteralCredentialsTest.php @@ -0,0 +1,135 @@ +`. + */ +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(" 'abcd1234'];")); + self::assertCount(1, self::literalsIn(sprintf(' \$generated];")); + } + + /** + * @return array{int, list} + */ + 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 [byte offset, literal] + */ + private static function literalsIn(string $source): array + { + $name = '(?[\w.-]*(?:password|passwd|secret|token|api_?key|private_?key)[\w.-]*)'; + $literal = '\'(?(?:[^\'\\\\]|\\\\.)*)\''; + + $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; + } +} diff --git a/tests/Unit/OutboundHttpChannelsTest.php b/tests/Unit/OutboundHttpChannelsTest.php index 97f3c0c..303a786 100644 --- a/tests/Unit/OutboundHttpChannelsTest.php +++ b/tests/Unit/OutboundHttpChannelsTest.php @@ -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; });