From 969ff1a4006d4fbc61c8a37aa1c8279f44e6aa44 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 19 Oct 2026 08:33:07 -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. Passwords and secrets the suite only needed as arbitrary values are now generated by TestCredentials, one plain-word purpose per value. The confirmation tests assert their matching and mismatching values differ, since the failing case depends on it. A guard fails on any literal added later. Rule tests whose input is the point (too short for a min(), missing exactly one character class, digits only) keep their literal and carry an exemption marker with the reason. package-tools 0.1.9 is the first release with TestCredentials and the guard, so the floor moves to ^0.1.9. --- CHANGELOG.md | 6 +++ composer.json | 2 +- .../Architecture/NoLiteralCredentialsTest.php | 12 +++++ tests/FastCheckParityTest.php | 13 ++--- tests/FluentRuleTest.php | 9 +++- tests/InlineMessageParamPhase3eTest.php | 3 +- tests/IntegrationTest.php | 2 + tests/MemoizingValidatorParityTest.php | 7 +-- tests/PasswordRuleTest.php | 53 +++++++++++++------ tests/RuleSetTest.php | 33 ++++++------ tests/RuleSetWithBagTest.php | 1 + tests/Rules/Net/UrlTest.php | 5 +- tests/StringRuleTest.php | 14 +++-- 13 files changed, 108 insertions(+), 52 deletions(-) create mode 100644 tests/Architecture/NoLiteralCredentialsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 21504e2..ec4ea47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,12 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se ### Changed +- **Test credentials are generated at run time.** Passwords and secrets the suite only needed + as arbitrary values are now `TestCredentials::password()` / `TestCredentials::secret()`, so no + literal a secret scanner would report reaches the repository, and + `tests/Architecture/NoLiteralCredentialsTest.php` fails on any literal added later. Inputs whose + exact value is what a rule test proves (too short, missing one character class) keep their + literal and are marked exempt with a reason. The `laranail/package-tools` floor is now `^0.1.9`. - `scripts/verify-tag-currency.sh` passes a tag that is behind `main` only by `.github/`-only commits (a Dependabot bump ships nothing), and tells a package on real releases to cut a patch rather than move a published tag. - `PostIncDecToPreIncDecRector` is skipped. Rector rewrote `$i++` to `++$i` in 21 files and Pint's `increment_style` rewrote every one of them back, so the two gates could never both pass. diff --git a/composer.json b/composer.json index 7cecec7..9c5a285 100644 --- a/composer.json +++ b/composer.json @@ -82,7 +82,7 @@ "illuminate/translation": "^13.0", "illuminate/validation": "^13.0", "laranail/console": "^0.1", - "laranail/package-tools": "^0.1.3", + "laranail/package-tools": "^0.1.9", "laravel/framework": "^13.0", "symfony/process": "^7.4 || ^8.0" }, diff --git a/tests/Architecture/NoLiteralCredentialsTest.php b/tests/Architecture/NoLiteralCredentialsTest.php new file mode 100644 index 0000000..8726269 --- /dev/null +++ b/tests/Architecture/NoLiteralCredentialsTest.php @@ -0,0 +1,12 @@ +assertNoLiteralCredentialsIn([dirname(__DIR__)], minFiles: 143); + expect(true)->toBeTrue(); +}); diff --git a/tests/FastCheckParityTest.php b/tests/FastCheckParityTest.php index d99f823..ebd5b9d 100644 --- a/tests/FastCheckParityTest.php +++ b/tests/FastCheckParityTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Validator; use Simtabi\Laranail\Validation\FastCheckCompiler; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * Parity suite: for every fast-checkable rule, the compiled closure's @@ -396,27 +397,27 @@ function itemAwareConfirmedParityGrid(): iterable $cases = [ 'default match' => [ 'required|confirmed', 'password', - 'hunter2', ['password' => 'hunter2', 'password_confirmation' => 'hunter2'], + TestCredentials::password('confirmed'), ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('confirmed')], ], 'default mismatch' => [ 'required|confirmed', 'password', - 'hunter2', ['password' => 'hunter2', 'password_confirmation' => 'hunter3'], + TestCredentials::password('confirmed'), ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('mismatch')], ], 'default confirmation missing' => [ 'required|confirmed', 'password', - 'hunter2', ['password' => 'hunter2'], + TestCredentials::password('confirmed'), ['password' => TestCredentials::password('confirmed')], ], 'default confirmation null' => [ 'required|confirmed', 'password', - 'hunter2', ['password' => 'hunter2', 'password_confirmation' => null], + TestCredentials::password('confirmed'), ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => null], ], 'custom name match' => [ 'required|confirmed:check', 'pwd', - 'hunter2', ['pwd' => 'hunter2', 'check' => 'hunter2'], + TestCredentials::password('confirmed'), ['pwd' => TestCredentials::password('confirmed'), 'check' => TestCredentials::password('confirmed')], ], 'custom name mismatch' => [ 'required|confirmed:check', 'pwd', - 'hunter2', ['pwd' => 'hunter2', 'check' => 'hunter3'], + TestCredentials::password('confirmed'), ['pwd' => TestCredentials::password('confirmed'), 'check' => TestCredentials::password('mismatch')], ], 'nullable value null' => [ 'nullable|confirmed', 'password', diff --git a/tests/FluentRuleTest.php b/tests/FluentRuleTest.php index 0e28cd3..c328dae 100644 --- a/tests/FluentRuleTest.php +++ b/tests/FluentRuleTest.php @@ -17,6 +17,7 @@ use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Validation\ValidationRule; use Simtabi\Laranail\Validation\Builder\Nodes\StringRule; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Validation\Tests\Fixtures\TestIntEnum; use Simtabi\Laranail\Validation\Tests\Fixtures\TestUnitEnum; use Simtabi\Laranail\Validation\Tests\Fixtures\TestStringEnum; @@ -487,6 +488,7 @@ ); $compiled = RuleSet::compile(['password' => $stringRule]); + // @credential-literal-exempt: shorter than the min(12) the admin branch applies $validator = makeValidator(['role' => 'admin', 'password' => 'short'], $compiled); expect($validator->passes())->toBeFalse(); }); @@ -498,6 +500,7 @@ ); $compiled = RuleSet::compile(['password' => $stringRule]); + // @credential-literal-exempt: shorter than the min(12) the skipped branch would apply, so passing proves the skip $validator = makeValidator(['role' => 'user', 'password' => 'short'], $compiled); expect($validator->passes())->toBeTrue(); }); @@ -510,6 +513,7 @@ ); $compiled = RuleSet::compile(['password' => $stringRule]); + // @credential-literal-exempt: longer than the max(5) the default branch applies $validator = makeValidator(['role' => 'user', 'password' => 'toolong'], $compiled); expect($validator->passes())->toBeFalse(); }); @@ -763,6 +767,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('uses message inside when() conditional', function (): void { $validator = makeValidator( + // @credential-literal-exempt: shorter than the minimum length the rule under test enforces ['password' => 'short'], ['password' => FluentRule::string()->required()->when(true, fn (StringRule $r): StringRule => $r->min(12)->message('Admin passwords need 12+ chars.'))], ); @@ -934,7 +939,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void }); it('validates field with prohibited', function (): void { - $v = makeValidator(['secret' => 'value'], ['secret' => FluentRule::string()->prohibited()]); + $v = makeValidator(['secret' => TestCredentials::secret('prohibited')], ['secret' => FluentRule::string()->prohibited()]); expect($v->passes())->toBeFalse(); $v = makeValidator([], ['secret' => FluentRule::string()->prohibited()]); @@ -945,7 +950,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $v = makeValidator([], ['secret' => FluentRule::string()->missing()]); expect($v->passes())->toBeTrue(); - $v = makeValidator(['secret' => 'value'], ['secret' => FluentRule::string()->missing()]); + $v = makeValidator(['secret' => TestCredentials::secret('missing')], ['secret' => FluentRule::string()->missing()]); expect($v->passes())->toBeFalse(); }); diff --git a/tests/InlineMessageParamPhase3eTest.php b/tests/InlineMessageParamPhase3eTest.php index b02ba5b..d33134d 100644 --- a/tests/InlineMessageParamPhase3eTest.php +++ b/tests/InlineMessageParamPhase3eTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Simtabi\Laranail\Validation\FluentRule; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; // ========================================================================= // Phase 3e — `message:` on FluentRule static factories. Every factory @@ -201,7 +202,7 @@ it('FluentRule::uuid(message: ...) surfaces in live validation', function (): void { $v = makeValidator( - ['token' => 'notuuid'], + ['token' => TestCredentials::secret('opaque')], ['token' => FluentRule::uuid(message: 'Must be a UUID.')], ); diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 161b110..cc1115d 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -174,6 +174,7 @@ // Note: Rule::when() requires an array or string, not a bare object. // Wrap the fluent rule in an array. $validator = makeValidator( + // @credential-literal-exempt: shorter than the minimum length the rule under test enforces ['secret' => 'short'], ['secret' => LaravelRule::when($isAdmin, [FluentRule::string()->required()->min(12)])], ); @@ -185,6 +186,7 @@ $isAdmin = false; $validator = makeValidator( + // @credential-literal-exempt: shorter than the min(12) the skipped branch would apply, so passing proves the skip ['secret' => 'short'], ['secret' => LaravelRule::when($isAdmin, [FluentRule::string()->required()->min(12)])], ); diff --git a/tests/MemoizingValidatorParityTest.php b/tests/MemoizingValidatorParityTest.php index 1b407e6..fecc230 100644 --- a/tests/MemoizingValidatorParityTest.php +++ b/tests/MemoizingValidatorParityTest.php @@ -8,6 +8,7 @@ use Simtabi\Laranail\Validation\MemoizingValidator; use Simtabi\Laranail\Validation\OptimizedValidator; use Illuminate\Validation\Validator as BaseValidator; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * Parity suite for {@see MemoizingValidator}. The class memoizes string-rule @@ -85,11 +86,11 @@ function memoParityScenarios(): iterable ['type' => 'required|in:person,company', 'vat' => 'required_if:type,company|string'], ]; yield 'required_with + same — mismatch' => [ - ['password' => 'secret', 'password_confirmation' => 'other'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('mismatch')], ['password' => 'required|string', 'password_confirmation' => 'required_with:password|same:password'], ]; yield 'confirmed — passing' => [ - ['password' => 'secret', 'password_confirmation' => 'secret'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('confirmed')], ['password' => 'required|string|confirmed'], ]; yield 'gt/lte field refs — passing' => [ @@ -117,7 +118,7 @@ function memoParityScenarios(): iterable ['role' => ['required', Rule::in(['admin', 'editor'])]], ]; yield 'closure rule bypass — failing' => [ - ['token' => 'nope'], + ['token' => TestCredentials::secret('closure')], ['token' => ['required', 'string', function (string $attribute, mixed $value, Closure $fail): void { if ($value !== 'ok') { $fail('The :attribute is not ok.'); diff --git a/tests/PasswordRuleTest.php b/tests/PasswordRuleTest.php index c736f74..ff0a6ff 100644 --- a/tests/PasswordRuleTest.php +++ b/tests/PasswordRuleTest.php @@ -4,6 +4,7 @@ use Illuminate\Validation\Rules\Password; use Simtabi\Laranail\Validation\FluentRule; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; // ========================================================================= // PasswordRule @@ -11,7 +12,7 @@ it('validates password with PasswordRule', function (): void { $validator = makeValidator( - ['password' => 'SecureP@ss1'], + ['password' => TestCredentials::password('valid')], ['password' => FluentRule::password()->required()->letters()->mixedCase()->numbers()->symbols()], ); expect($validator->passes())->toBeTrue(); @@ -19,6 +20,7 @@ it('rejects weak password', function (): void { $validator = makeValidator( + // @credential-literal-exempt: a deliberately weak input the rule must reject ['password' => 'weak'], ['password' => FluentRule::password(8)->required()->letters()->numbers()], ); @@ -27,12 +29,14 @@ it('validates password min length', function (): void { $v = makeValidator( + // @credential-literal-exempt: exactly the 8-character minimum, the boundary under test ['password' => 'abcdefgh'], ['password' => FluentRule::password(8)->required()], ); expect($v->passes())->toBeTrue(); $v = makeValidator( + // @credential-literal-exempt: shorter than the minimum length the rule under test enforces ['password' => 'short'], ['password' => FluentRule::password(8)->required()], ); @@ -86,55 +90,63 @@ $passwordRule = FluentRule::password()->required()->letters()->mixedCase()->numbers()->symbols(); // Missing uppercase + // @credential-literal-exempt: crafted to miss exactly one required character class $v = makeValidator(['password' => 'abcdefg1!'], ['password' => $passwordRule]); expect($v->passes())->toBeFalse(); // Missing number + // @credential-literal-exempt: crafted to miss exactly one required character class $v = makeValidator(['password' => 'Abcdefg!!'], ['password' => $passwordRule]); expect($v->passes())->toBeFalse(); // Missing symbol + // @credential-literal-exempt: crafted to miss exactly one required character class $v = makeValidator(['password' => 'Abcdefg12'], ['password' => $passwordRule]); expect($v->passes())->toBeFalse(); // Missing letter + // @credential-literal-exempt: crafted to miss exactly one required character class $v = makeValidator(['password' => '12345678!'], ['password' => $passwordRule]); expect($v->passes())->toBeFalse(); // All satisfied - $v = makeValidator(['password' => 'Abcdef1!x'], ['password' => $passwordRule]); + $v = makeValidator(['password' => TestCredentials::password('complete')], ['password' => $passwordRule]); expect($v->passes())->toBeTrue(); }); it('password letters requires at least one letter', function (): void { + // @credential-literal-exempt: digits only, so the letters() rule must reject it $v = makeValidator(['password' => '12345678'], ['password' => FluentRule::password()->required()->letters()]); expect($v->passes())->toBeFalse(); - $v = makeValidator(['password' => '1234567a'], ['password' => FluentRule::password()->required()->letters()]); + $v = makeValidator(['password' => TestCredentials::password('lettered')], ['password' => FluentRule::password()->required()->letters()]); expect($v->passes())->toBeTrue(); }); it('password mixedCase requires upper and lower', function (): void { + // @credential-literal-exempt: lower case only, so the mixedCase() rule must reject it $v = makeValidator(['password' => 'alllowercase'], ['password' => FluentRule::password()->required()->mixedCase()]); expect($v->passes())->toBeFalse(); - $v = makeValidator(['password' => 'hasUpperA'], ['password' => FluentRule::password()->required()->mixedCase()]); + $v = makeValidator(['password' => TestCredentials::password('mixed')], ['password' => FluentRule::password()->required()->mixedCase()]); expect($v->passes())->toBeTrue(); }); it('password numbers requires at least one number', function (): void { + // @credential-literal-exempt: no digit, so the numbers() rule must reject it $v = makeValidator(['password' => 'NoNumbers!'], ['password' => FluentRule::password()->required()->numbers()]); expect($v->passes())->toBeFalse(); - $v = makeValidator(['password' => 'Has1Number'], ['password' => FluentRule::password()->required()->numbers()]); + $v = makeValidator(['password' => TestCredentials::password('numbered')], ['password' => FluentRule::password()->required()->numbers()]); expect($v->passes())->toBeTrue(); }); it('password symbols requires at least one symbol', function (): void { + // @credential-literal-exempt: no symbol, so the symbols() rule must reject it $v = makeValidator(['password' => 'NoSymbols1'], ['password' => FluentRule::password()->required()->symbols()]); expect($v->passes())->toBeFalse(); - $v = makeValidator(['password' => 'HasSymbol!'], ['password' => FluentRule::password()->required()->symbols()]); + $v = makeValidator(['password' => TestCredentials::password('symbolic')], ['password' => FluentRule::password()->required()->symbols()]); expect($v->passes())->toBeTrue(); }); @@ -143,10 +155,11 @@ Password::defaults(fn () => Password::min(10)->letters()->numbers()); try { + // @credential-literal-exempt: shorter than the configured default minimum of 10 $v = makeValidator(['password' => 'short1'], ['password' => FluentRule::password()->required()]); expect($v->passes())->toBeFalse(); // too short (min 10) - $v = makeValidator(['password' => 'LongEnough1'], ['password' => FluentRule::password()->required()]); + $v = makeValidator(['password' => TestCredentials::password('sufficient')], ['password' => FluentRule::password()->required()]); expect($v->passes())->toBeTrue(); } finally { Password::$defaultCallback = null; @@ -157,7 +170,7 @@ Password::defaults(fn () => Password::min(20)->letters()); try { - $v = makeValidator(['password' => 'Short1'], ['password' => FluentRule::password(min: 6)->required()]); + $v = makeValidator(['password' => TestCredentials::password('brief', 8)], ['password' => FluentRule::password(min: 6)->required()]); expect($v->passes())->toBeTrue(); } finally { Password::$defaultCallback = null; @@ -169,62 +182,68 @@ try { // defaults: false → Password::min(8), ignores the configured min:20 + letters + numbers - $v = makeValidator(['password' => 'simplepassword'], ['password' => FluentRule::password(defaults: false)->required()]); + $v = makeValidator(['password' => TestCredentials::password('plain', 14)], ['password' => FluentRule::password(defaults: false)->required()]); expect($v->passes())->toBeTrue(); // passes with min:8, no letters/numbers requirement // With defaults: true (default) → applies min:20 + letters + numbers - $v = makeValidator(['password' => 'simplepassword'], ['password' => FluentRule::password()->required()]); - expect($v->passes())->toBeFalse(); // fails: too short (min:20) and no numbers + $v = makeValidator(['password' => TestCredentials::password('plain', 14)], ['password' => FluentRule::password()->required()]); + expect($v->passes())->toBeFalse(); // fails: too short (min:20) } finally { Password::$defaultCallback = null; } }); it('min() sets minimum length via chain', function (): void { + // @credential-literal-exempt: shorter than the minimum length the rule under test enforces $v = makeValidator(['password' => 'short'], ['password' => FluentRule::password()->min(8)->required()]); expect($v->passes())->toBeFalse(); - $v = makeValidator(['password' => 'longenough'], ['password' => FluentRule::password()->min(8)->required()]); + $v = makeValidator(['password' => TestCredentials::password('long')], ['password' => FluentRule::password()->min(8)->required()]); expect($v->passes())->toBeTrue(); }); it('min() and max() work together', function (): void { $rule = FluentRule::password()->min(8)->max(20)->required(); + // @credential-literal-exempt: shorter than the minimum length the rule under test enforces $v = makeValidator(['password' => 'short'], ['password' => $rule]); expect($v->passes())->toBeFalse(); // too short - $v = makeValidator(['password' => 'this_password_is_way_too_long_for_the_max'], ['password' => $rule]); + $v = makeValidator(['password' => TestCredentials::password('excessive', 41)], ['password' => $rule]); expect($v->passes())->toBeFalse(); // too long - $v = makeValidator(['password' => 'justright123'], ['password' => $rule]); + $v = makeValidator(['password' => TestCredentials::password('balanced', 12)], ['password' => $rule]); expect($v->passes())->toBeTrue(); }); it('min() preserves other password rules', function (): void { $rule = FluentRule::password()->min(10)->letters()->numbers(); + // @credential-literal-exempt: digits only, so the letters() rule must reject it $v = makeValidator(['password' => '1234567890'], ['password' => $rule]); expect($v->passes())->toBeFalse(); // no letters + // @credential-literal-exempt: letters only, so the numbers() rule must reject it $v = makeValidator(['password' => 'abcdefghij'], ['password' => $rule]); expect($v->passes())->toBeFalse(); // no numbers - $v = makeValidator(['password' => 'HasLetters1'], ['password' => $rule]); + $v = makeValidator(['password' => TestCredentials::password('alphanumeric')], ['password' => $rule]); expect($v->passes())->toBeTrue(); }); it('confirmed() works on password rule', function (): void { + expect(TestCredentials::password('mismatch'))->not->toBe(TestCredentials::password('confirmed')); + $rule = FluentRule::password()->required()->confirmed(); $v = makeValidator( - ['password' => 'MyPassword1', 'password_confirmation' => 'MyPassword1'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('confirmed')], ['password' => $rule], ); expect($v->passes())->toBeTrue(); $v = makeValidator( - ['password' => 'MyPassword1', 'password_confirmation' => 'Different1'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('mismatch')], ['password' => $rule], ); expect($v->passes())->toBeFalse(); diff --git a/tests/RuleSetTest.php b/tests/RuleSetTest.php index 50ab2ca..1b7a37d 100644 --- a/tests/RuleSetTest.php +++ b/tests/RuleSetTest.php @@ -16,6 +16,7 @@ use Simtabi\Laranail\Validation\Builder\Nodes\ArrayRule; use Simtabi\Laranail\Validation\Builder\Nodes\StringRule; use Simtabi\Laranail\Validation\Builder\Nodes\NumericRule; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Validation\Tests\Fixtures\TestStringEnum; // ========================================================================= @@ -821,6 +822,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void try { RuleSet::from([ 'secret' => ['bail', FluentRule::string('API Secret')->required()->min(20)], + // @credential-literal-exempt: shorter than the minimum length the rule under test enforces ])->validate(['secret' => 'short']); } catch (ValidationException $validationException) { expect($validationException->errors()['secret'][0])->toContain('API Secret'); @@ -2225,7 +2227,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void ]), ])->dropUnknownFields()->validate([ 'name' => 'John', - 'meta' => ['type' => 'admin', 'secret' => 'leak'], + 'meta' => ['type' => 'admin', 'secret' => TestCredentials::secret('unknown')], ]); expect($validated) @@ -2240,7 +2242,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void 'meta' => FluentRule::array()->required(), 'meta.type' => FluentRule::string()->required(), ])->dropUnknownFields()->validate([ - 'meta' => ['type' => 'admin', 'secret' => 'leak'], + 'meta' => ['type' => 'admin', 'secret' => TestCredentials::secret('unknown')], ]); expect($validated['meta']) @@ -2275,7 +2277,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void 'type' => FluentRule::string()->required(), ]), ])->dropUnknownFields()->validate([ - 'meta' => ['type' => 'admin', 'secret' => 'leak'], + 'meta' => ['type' => 'admin', 'secret' => TestCredentials::secret('unknown')], ]); expect($validated['meta'])->not->toHaveKey('secret'); @@ -2294,10 +2296,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void 'type' => FluentRule::string()->required(), ]), ])->validate([ - 'meta' => ['type' => 'admin', 'secret' => 'keep'], + 'meta' => ['type' => 'admin', 'secret' => TestCredentials::secret('retained')], ]); - expect($validated['meta'])->toHaveKey('secret', 'keep'); + expect($validated['meta'])->toHaveKey('secret', TestCredentials::secret('retained')); } finally { $factory->excludeUnvalidatedArrayKeys(); } @@ -2322,7 +2324,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void 'type' => FluentRule::string()->required(), ]), ])->dropUnknownFields()->stopOnFirstFailure()->validate([ - 'meta' => ['type' => 'admin', 'secret' => 'leak'], + 'meta' => ['type' => 'admin', 'secret' => TestCredentials::secret('unknown')], ]); expect($validated['meta'])->not->toHaveKey('secret'); @@ -2574,14 +2576,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void ]), ]; + expect(TestCredentials::password('mismatch'))->not->toBe(TestCredentials::password('confirmed')); // Mismatch → fail. expect(fn () => RuleSet::from($rules)->validate(['users' => [ - ['password' => 'hunter2', 'password_confirmation' => 'hunter3'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('mismatch')], ]]))->toThrow(ValidationException::class); // Match → pass. $valid = ['users' => [ - ['password' => 'hunter2', 'password_confirmation' => 'hunter2'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('confirmed')], ]]; expect(RuleSet::from($rules)->validate($valid))->toBe($valid); }); @@ -2666,21 +2669,21 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Mismatched confirmation → fail. expect(fn () => RuleSet::from($rules)->validate(['users' => [ - ['password' => 'hunter2', 'password_confirmation' => 'hunter3'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('mismatch')], ]]))->toThrow(ValidationException::class); // Missing confirmation → fail. expect(fn () => RuleSet::from($rules)->validate(['users' => [ - ['password' => 'hunter2'], + ['password' => TestCredentials::password('confirmed')], ]]))->toThrow(ValidationException::class); // Matching confirmation → pass (but `password_confirmation` is stripped // from validated output since it isn't in the rule set). $valid = ['users' => [ - ['password' => 'hunter2', 'password_confirmation' => 'hunter2'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('confirmed')], ]]; $result = RuleSet::from($rules)->validate($valid); - expect($result['users'][0]['password'])->toBe('hunter2'); + expect($result['users'][0]['password'])->toBe(TestCredentials::password('confirmed')); }); it('wildcard fast path enforces confirmed:custom_name', function (): void { @@ -2692,14 +2695,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Mismatch → fail. expect(fn () => RuleSet::from($rules)->validate(['users' => [ - ['pwd' => 'hunter2', 'pwd_check' => 'hunter3'], + ['pwd' => TestCredentials::password('confirmed'), 'pwd_check' => TestCredentials::password('mismatch')], ]]))->toThrow(ValidationException::class); // Match → pass. $valid = ['users' => [ - ['pwd' => 'hunter2', 'pwd_check' => 'hunter2'], + ['pwd' => TestCredentials::password('confirmed'), 'pwd_check' => TestCredentials::password('confirmed')], ]]; - expect(RuleSet::from($rules)->validate($valid)['users'][0]['pwd'])->toBe('hunter2'); + expect(RuleSet::from($rules)->validate($valid)['users'][0]['pwd'])->toBe(TestCredentials::password('confirmed')); }); it('wildcard fast path enforces different:FIELD against sibling', function (): void { diff --git a/tests/RuleSetWithBagTest.php b/tests/RuleSetWithBagTest.php index 2311df2..967080a 100644 --- a/tests/RuleSetWithBagTest.php +++ b/tests/RuleSetWithBagTest.php @@ -90,6 +90,7 @@ ])->withBag('updatePassword'); try { + // @credential-literal-exempt: both values are shorter than the min() rules, so the bag must report them $ruleSet->validate(['current_password' => 'short', 'new_password' => 'alsoshort']); throw new RuntimeException('should have thrown'); } catch (ValidationException $validationException) { diff --git a/tests/Rules/Net/UrlTest.php b/tests/Rules/Net/UrlTest.php index e5b3db1..ca174a4 100644 --- a/tests/Rules/Net/UrlTest.php +++ b/tests/Rules/Net/UrlTest.php @@ -4,6 +4,7 @@ use Simtabi\Laranail\Validation\RuleSet; use Simtabi\Laranail\Validation\FluentRule; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /* * Every value in the first block passes Laravel's own `url` rule. That is the @@ -66,9 +67,9 @@ function urlError(mixed $rule, mixed $value): string it('rejects credentials in the authority by default', function (): void { // A submitted URL carrying a password ends up in a database, in a log // line, and in the referrer of whatever the application fetches next. - expect(urlCheck(FluentRule::url(), 'https://user:password@example.com/'))->toBeFalse() + expect(urlCheck(FluentRule::url(), sprintf('https://user:%s@example.com/', TestCredentials::secret('userinfo'))))->toBeFalse() ->and(urlCheck(FluentRule::url(), 'https://user@example.com/'))->toBeFalse() - ->and(urlCheck(FluentRule::url()->allowCredentials(), 'https://user:password@example.com/'))->toBeTrue(); + ->and(urlCheck(FluentRule::url()->allowCredentials(), sprintf('https://user:%s@example.com/', TestCredentials::secret('userinfo'))))->toBeTrue(); }); it('rejects a host that is not a domain', function (string $value): void { diff --git a/tests/StringRuleTest.php b/tests/StringRuleTest.php index f1d6317..54a7cc8 100644 --- a/tests/StringRuleTest.php +++ b/tests/StringRuleTest.php @@ -5,6 +5,7 @@ use Simtabi\Laranail\Validation\FluentRule; use Illuminate\Contracts\Validation\ValidationRule; use Simtabi\Laranail\Validation\Builder\Nodes\StringRule; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Validation\Tests\Fixtures\TestStringEnum; // ========================================================================= @@ -50,6 +51,7 @@ it('validates string with chained modifiers and constraints', function (): void { $v = makeValidator( + // @credential-literal-exempt: shorter than the minimum length the rule under test enforces ['password' => 'short'], ['password' => FluentRule::string()->required()->when(true, fn (StringRule $r): StringRule => $r->min(12))->max(255)], ); @@ -57,7 +59,7 @@ expect($v->passes())->toBeFalse(); $v = makeValidator( - ['password' => 'longenoughpassword'], + ['password' => TestCredentials::password('long')], ['password' => FluentRule::string()->required()->when(true, fn (StringRule $r): StringRule => $r->min(12))->max(255)], ); @@ -435,14 +437,16 @@ public function validate(string $attribute, mixed $value, Closure $fail): void }); it('validates string with confirmed', function (): void { + expect(TestCredentials::password('mismatch'))->not->toBe(TestCredentials::password('confirmed')); + $v = makeValidator( - ['password' => 'secret', 'password_confirmation' => 'secret'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('confirmed')], ['password' => FluentRule::string()->confirmed()], ); expect($v->passes())->toBeTrue(); $v = makeValidator( - ['password' => 'secret', 'password_confirmation' => 'different'], + ['password' => TestCredentials::password('confirmed'), 'password_confirmation' => TestCredentials::password('mismatch')], ['password' => FluentRule::string()->confirmed()], ); expect($v->passes())->toBeFalse(); @@ -450,13 +454,13 @@ public function validate(string $attribute, mixed $value, Closure $fail): void it('validates string with same', function (): void { $v = makeValidator( - ['password' => 'secret', 'confirm' => 'secret'], + ['password' => TestCredentials::password('confirmed'), 'confirm' => TestCredentials::password('confirmed')], ['password' => FluentRule::string()->same('confirm')], ); expect($v->passes())->toBeTrue(); $v = makeValidator( - ['password' => 'secret', 'confirm' => 'other'], + ['password' => TestCredentials::password('confirmed'), 'confirm' => TestCredentials::password('mismatch')], ['password' => FluentRule::string()->same('confirm')], ); expect($v->passes())->toBeFalse(); From 1b0eb0244f67d3fd20e3e621c9ed558c3d1cdddd 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 | 10 ++++++---- composer.json | 2 +- tests/Architecture/NoLiteralCredentialsTest.php | 5 +++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4ea47..a7e06a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,12 +44,14 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se ### Changed -- **Test credentials are generated at run time.** Passwords and secrets the suite only needed - as arbitrary values are now `TestCredentials::password()` / `TestCredentials::secret()`, so no +- **Test credentials are generated at run time.** Passwords and secrets the suite only needed as + arbitrary values are now `TestCredentials::password()` / `TestCredentials::secret()`, so no literal a secret scanner would report reaches the repository, and `tests/Architecture/NoLiteralCredentialsTest.php` fails on any literal added later. Inputs whose - exact value is what a rule test proves (too short, missing one character class) keep their - literal and are marked exempt with a reason. The `laranail/package-tools` floor is now `^0.1.9`. + exact value is what a rule test proves (too short, missing one character class) keep their literal + and are marked exempt with a reason. 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. - `scripts/verify-tag-currency.sh` passes a tag that is behind `main` only by `.github/`-only commits (a Dependabot bump ships nothing), and tells a package on real releases to cut a patch rather than move a published tag. - `PostIncDecToPreIncDecRector` is skipped. Rector rewrote `$i++` to `++$i` in 21 files and Pint's `increment_style` rewrote every one of them back, so the two gates could never both pass. diff --git a/composer.json b/composer.json index 9c5a285..2245960 100644 --- a/composer.json +++ b/composer.json @@ -82,7 +82,7 @@ "illuminate/translation": "^13.0", "illuminate/validation": "^13.0", "laranail/console": "^0.1", - "laranail/package-tools": "^0.1.9", + "laranail/package-tools": "^0.1.10", "laravel/framework": "^13.0", "symfony/process": "^7.4 || ^8.0" }, diff --git a/tests/Architecture/NoLiteralCredentialsTest.php b/tests/Architecture/NoLiteralCredentialsTest.php index 8726269..977cb9a 100644 --- a/tests/Architecture/NoLiteralCredentialsTest.php +++ b/tests/Architecture/NoLiteralCredentialsTest.php @@ -10,3 +10,8 @@ $this->assertNoLiteralCredentialsIn([dirname(__DIR__)], minFiles: 143); expect(true)->toBeTrue(); }); + +it('names every generated credential with a single word', function (): void { + $this->assertCredentialPurposesAreWordsIn([dirname(__DIR__)], minFiles: 143); + expect(true)->toBeTrue(); +});