From dcdc9f90c4d8c037d8b2f8341be4cb874fcd6ba8 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 19 Oct 2026 08:05:41 -0400 Subject: [PATCH 1/2] Generate test credentials at run time GitGuardian raised 28 incidents across laranail by 2026-10-18, 27 of them literal test credentials or false positives, each closed by hand. A provider secret spelled out in a test reads exactly like a leaked one. Every provider secret the suite configured is now generated by TestCredentials. The redaction tests hunt for the same generated value they configured, and the database-over-config test asserts its two secrets differ, since that is what proves the row won. A guard fails on any literal added later. package-tools 0.1.9 is the first release with TestCredentials and the guard, so the floor moves to ^0.1.9. --- CHANGELOG.md | 5 +++++ composer.json | 2 +- tests/Arch/NoLiteralCredentialsTest.php | 12 ++++++++++++ tests/Feature/AdapterContractTest.php | 7 ++++--- tests/Feature/BrowserRuntimeTest.php | 5 +++-- tests/Feature/CommandsTest.php | 9 +++++---- tests/Feature/CredentialResolutionTest.php | 12 ++++++++---- tests/Feature/RuntimeScriptTest.php | 3 ++- .../Feature/Security/PostVerificationChecksTest.php | 9 +++++---- tests/Feature/WidgetTest.php | 3 ++- 10 files changed, 47 insertions(+), 20 deletions(-) create mode 100644 tests/Arch/NoLiteralCredentialsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bafe7..1d1f055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- **Test credentials are generated at run time.** The provider secrets the suite configured as + literals are now `TestCredentials::secret('')`, so no literal a secret scanner would + report reaches the repository, and `tests/Arch/NoLiteralCredentialsTest.php` fails on any + literal added later. The `laranail/package-tools` floor is now `^0.1.9`, the first release with + `TestCredentials` and the guard. - The components render through `laranail/captcha::components.*`. `laranail-captcha::` still resolves the same files. - Docs, the README and the install command lead with the scoped tags and rule. diff --git a/composer.json b/composer.json index db0fc4e..673f68e 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "laranail/console": "^0.1", "laranail/db-tools": "^0.1", "laranail/enumerator": "^0.1", - "laranail/package-tools": "^0.1.3", + "laranail/package-tools": "^0.1.9", "psr/clock": "^1.0", "psr/log": "^3.0" }, diff --git a/tests/Arch/NoLiteralCredentialsTest.php b/tests/Arch/NoLiteralCredentialsTest.php new file mode 100644 index 0000000..6fa707c --- /dev/null +++ b/tests/Arch/NoLiteralCredentialsTest.php @@ -0,0 +1,12 @@ +assertNoLiteralCredentialsIn([dirname(__DIR__)], minFiles: 24); + expect(true)->toBeTrue(); +}); diff --git a/tests/Feature/AdapterContractTest.php b/tests/Feature/AdapterContractTest.php index 83fc7f1..bf4a1cb 100644 --- a/tests/Feature/AdapterContractTest.php +++ b/tests/Feature/AdapterContractTest.php @@ -8,6 +8,7 @@ use Simtabi\Laranail\Captcha\Enums\ErrorCode; use Illuminate\Http\Client\ConnectionException; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Captcha\ValueObjects\VerificationContext; /** @@ -30,7 +31,7 @@ foreach (httpProviders() as $provider) { $credentials[$provider->value] = [ 'site_key' => 'site-key', - 'secret' => 'secret-key', + 'secret' => TestCredentials::secret('captcha'), 'project_id' => 'demo-project', 'client' => 'demo', ]; @@ -115,13 +116,13 @@ function httpProviders(): array })->with('http adapters'); it('never leaks the secret into the failure it returns', function (Provider $provider): void { - Http::fake(fn () => throw new ConnectionException('POST failed: secret=secret-key')); + Http::fake(fn () => throw new ConnectionException('POST failed: secret=' . TestCredentials::secret('captcha'))); $result = app(AdapterFactory::class)->make($provider)->verify('a-token', VerificationContext::none()); // A Guzzle RequestException stringifies the request it failed on, and that request body // carries the secret. Reporting the exception verbatim is how a secret reaches the log. - expect(json_encode($result->toArray()))->not->toContain('secret-key'); + expect(json_encode($result->toArray()))->not->toContain(TestCredentials::secret('captcha')); })->with('http adapters'); it('passes a genuine success through', function (Provider $provider, array $body): void { diff --git a/tests/Feature/BrowserRuntimeTest.php b/tests/Feature/BrowserRuntimeTest.php index 92b49ee..7131f85 100644 --- a/tests/Feature/BrowserRuntimeTest.php +++ b/tests/Feature/BrowserRuntimeTest.php @@ -6,6 +6,7 @@ use Symfony\Component\Process\Process; use Simtabi\Laranail\Captcha\Services\CaptchaService; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * Runs the widget in a real browser. @@ -118,8 +119,8 @@ function writeManifest(array $pages): void // Three shapes, because the runtime branches on all three: a server-rendered challenge with no // vendor script, a hosted widget holding live state, and a provider with nothing to click. writeFixture('math', 'math', []); - writeFixture('turnstile', 'turnstile', ['site_key' => 'site-key-abc', 'secret' => 'secret-key']); - writeFixture('recaptcha-v3', 'recaptcha-v3', ['site_key' => 'site-key-abc', 'secret' => 'secret-key']); + writeFixture('turnstile', 'turnstile', ['site_key' => 'site-key-abc', 'secret' => TestCredentials::secret('captcha')]); + writeFixture('recaptcha-v3', 'recaptcha-v3', ['site_key' => 'site-key-abc', 'secret' => TestCredentials::secret('captcha')]); writeManifest(['math', 'turnstile', 'recaptcha-v3']); diff --git a/tests/Feature/CommandsTest.php b/tests/Feature/CommandsTest.php index 7437616..8d6ce58 100644 --- a/tests/Feature/CommandsTest.php +++ b/tests/Feature/CommandsTest.php @@ -6,6 +6,7 @@ use Simtabi\Laranail\Captcha\Enums\Provider; use Simtabi\Laranail\Captcha\Models\CaptchaSetting; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * The commands, actually run. @@ -44,7 +45,7 @@ config()->set('laranail.captcha.provider', 'turnstile'); config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key-abcdef', - 'secret' => 'a-secret', + 'secret' => TestCredentials::secret('captcha'), ]); config()->set('laranail.captcha.verification.enforce_hostname', true); config()->set('laranail.captcha.verification.allowed_hostnames', []); @@ -71,7 +72,7 @@ it('shows where every provider resolves its credentials from', function (): void { config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key-abcdef', - 'secret' => 'a-secret', + 'secret' => TestCredentials::secret('captcha'), ]); app()->forgetInstance(CredentialStore::class); @@ -83,13 +84,13 @@ it('never prints a secret', function (): void { config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key-abcdef', - 'secret' => 'the-actual-secret', + 'secret' => TestCredentials::secret('printed'), ]); app()->forgetInstance(CredentialStore::class); $this->artisan('laranail::captcha.keys')->assertSuccessful(); - expect(app(Kernel::class)->output())->not->toContain('the-actual-secret'); + expect(app(Kernel::class)->output())->not->toContain(TestCredentials::secret('printed')); }); it('publishes the configuration', function (): void { diff --git a/tests/Feature/CredentialResolutionTest.php b/tests/Feature/CredentialResolutionTest.php index 8aa3853..cd4f46e 100644 --- a/tests/Feature/CredentialResolutionTest.php +++ b/tests/Feature/CredentialResolutionTest.php @@ -7,6 +7,7 @@ use Simtabi\Laranail\Captcha\Models\CaptchaSetting; use Simtabi\Laranail\Captcha\Enums\CredentialSource; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * The resolution order the package promises: database, then config, then test keys. @@ -19,7 +20,7 @@ config()->set('laranail.captcha.credentials.database.enabled', true); config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'config-site-key', - 'secret' => 'config-secret', + 'secret' => TestCredentials::secret('config'), ]); app()->forgetInstance(CredentialStore::class); @@ -39,14 +40,17 @@ function setting(string $environment, string $key, string $value): void // Written through the model so the encrypted cast round-trips exactly as it will in an // application. A raw builder insert would bypass the cast and prove nothing about the read. setting('testing', 'site_key', 'db-site-key'); - setting('testing', 'secret', 'db-secret'); + setting('testing', 'secret', TestCredentials::secret('database')); $credentials = app(CredentialStore::class)->get(Provider::Turnstile, 'testing'); + // The secret assertion below proves the row won only while the two secrets differ. + expect(TestCredentials::secret('database'))->not->toBe(TestCredentials::secret('config')); + // An operator changing a key in an admin UI has to beat the .env the application booted with, // or the admin UI is decorative. expect($credentials?->siteKey)->toBe('db-site-key') - ->and($credentials?->secret)->toBe('db-secret') + ->and($credentials?->secret)->toBe(TestCredentials::secret('database')) ->and($credentials?->source)->toBe(CredentialSource::Database); }); @@ -59,7 +63,7 @@ function setting(string $environment, string $key, string $value): void it('scopes rows to the environment they were written for', function (): void { setting('production', 'site_key', 'prod-site-key'); - setting('production', 'secret', 'prod-secret'); + setting('production', 'secret', TestCredentials::secret('production')); // A production row must not answer for a staging lookup. This is the whole reason the // environment column is not nullable. diff --git a/tests/Feature/RuntimeScriptTest.php b/tests/Feature/RuntimeScriptTest.php index 52e52b7..8246586 100644 --- a/tests/Feature/RuntimeScriptTest.php +++ b/tests/Feature/RuntimeScriptTest.php @@ -6,6 +6,7 @@ use Symfony\Component\Process\Process; use Simtabi\Laranail\Captcha\Services\CaptchaService; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * Runs the emitted JavaScript, rather than asserting it was emitted. @@ -28,7 +29,7 @@ function extractRuntime(): string config()->set('laranail.captcha.provider', 'turnstile'); config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key-abc', - 'secret' => 'secret-key', + 'secret' => TestCredentials::secret('captcha'), ]); app()->forgetInstance(CaptchaService::class); diff --git a/tests/Feature/Security/PostVerificationChecksTest.php b/tests/Feature/Security/PostVerificationChecksTest.php index 7ce37ae..64ef3bf 100644 --- a/tests/Feature/Security/PostVerificationChecksTest.php +++ b/tests/Feature/Security/PostVerificationChecksTest.php @@ -8,6 +8,7 @@ use Simtabi\Laranail\Captcha\Enums\ErrorCode; use Simtabi\Laranail\Captcha\Services\CaptchaService; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Captcha\ValueObjects\VerificationContext; /** @@ -22,7 +23,7 @@ config()->set('laranail.captcha.provider', 'turnstile'); config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key', - 'secret' => 'secret-key', + 'secret' => TestCredentials::secret('captcha'), ]); freezeClockAt('2026-08-12T12:00:00+00:00'); @@ -132,7 +133,7 @@ function turnstileAnswers(array $overrides = []): void it('blocks a score below the review threshold', function (): void { config()->set('laranail.captcha.provider', 'recaptcha-v3'); config()->set('laranail.captcha.environments.default.recaptcha-v3', [ - 'site_key' => 'site-key', 'secret' => 'secret-key', + 'site_key' => 'site-key', 'secret' => TestCredentials::secret('captcha'), ]); Http::fake(fn () => Http::response(['success' => true, 'score' => 0.1], 200)); @@ -144,7 +145,7 @@ function turnstileAnswers(array $overrides = []): void it('marks a middling score for review rather than blocking it', function (): void { config()->set('laranail.captcha.provider', 'recaptcha-v3'); config()->set('laranail.captcha.environments.default.recaptcha-v3', [ - 'site_key' => 'site-key', 'secret' => 'secret-key', + 'site_key' => 'site-key', 'secret' => TestCredentials::secret('captcha'), ]); Http::fake(fn () => Http::response(['success' => true, 'score' => 0.4], 200)); @@ -159,7 +160,7 @@ function turnstileAnswers(array $overrides = []): void it('allows a high score outright', function (): void { config()->set('laranail.captcha.provider', 'recaptcha-v3'); config()->set('laranail.captcha.environments.default.recaptcha-v3', [ - 'site_key' => 'site-key', 'secret' => 'secret-key', + 'site_key' => 'site-key', 'secret' => TestCredentials::secret('captcha'), ]); Http::fake(fn () => Http::response(['success' => true, 'score' => 0.9], 200)); diff --git a/tests/Feature/WidgetTest.php b/tests/Feature/WidgetTest.php index 328ac0c..3ec7319 100644 --- a/tests/Feature/WidgetTest.php +++ b/tests/Feature/WidgetTest.php @@ -6,13 +6,14 @@ use Simtabi\Laranail\Captcha\Enums\Provider; use Simtabi\Laranail\Captcha\Services\CaptchaService; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; function useProvider(string $provider): void { config()->set('laranail.captcha.provider', $provider); config()->set("laranail.captcha.environments.default.{$provider}", [ 'site_key' => 'site-key-abc', - 'secret' => 'secret-key', + 'secret' => TestCredentials::secret('captcha'), ]); app()->forgetInstance(CaptchaService::class); From 41f146d1919219df949627c71accaf2a5721d9c4 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 19 Oct 2026 10:26:32 -0400 Subject: [PATCH 2/2] Adopt package-tools 0.1.10's credential checks GitGuardian reads a compound purpose, two words joined by a hyphen, as the password itself, which turns a generated credential back into a reported one. package-tools 0.1.10 ships assertCredentialPurposesAreWordsIn(), so the floor moves to ^0.1.10 and the credential guard gains a second test that keeps every purpose a single plain lowercase word. No purpose in this suite needed renaming, and every remaining exemption marker is still required by the 0.1.10 scanner. --- CHANGELOG.md | 7 ++++--- composer.json | 2 +- tests/Arch/NoLiteralCredentialsTest.php | 5 +++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d1f055..cbdae7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Test credentials are generated at run time.** The provider secrets the suite configured as literals are now `TestCredentials::secret('')`, so no literal a secret scanner would - report reaches the repository, and `tests/Arch/NoLiteralCredentialsTest.php` fails on any - literal added later. The `laranail/package-tools` floor is now `^0.1.9`, the first release with - `TestCredentials` and the guard. + report reaches the repository, and `tests/Arch/NoLiteralCredentialsTest.php` fails on any literal + added later. The `laranail/package-tools` floor is now `^0.1.10`, and a second check keeps every + purpose passed to `TestCredentials` a single plain word, since a secret scanner reads a compound + purpose as the password itself. - The components render through `laranail/captcha::components.*`. `laranail-captcha::` still resolves the same files. - Docs, the README and the install command lead with the scoped tags and rule. diff --git a/composer.json b/composer.json index 673f68e..64501dd 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "laranail/console": "^0.1", "laranail/db-tools": "^0.1", "laranail/enumerator": "^0.1", - "laranail/package-tools": "^0.1.9", + "laranail/package-tools": "^0.1.10", "psr/clock": "^1.0", "psr/log": "^3.0" }, diff --git a/tests/Arch/NoLiteralCredentialsTest.php b/tests/Arch/NoLiteralCredentialsTest.php index 6fa707c..1619832 100644 --- a/tests/Arch/NoLiteralCredentialsTest.php +++ b/tests/Arch/NoLiteralCredentialsTest.php @@ -10,3 +10,8 @@ $this->assertNoLiteralCredentialsIn([dirname(__DIR__)], minFiles: 24); expect(true)->toBeTrue(); }); + +it('names every generated credential with a single word', function (): void { + $this->assertCredentialPurposesAreWordsIn([dirname(__DIR__)], minFiles: 24); + expect(true)->toBeTrue(); +});