diff --git a/.github/workflows/hygiene.yml b/.github/workflows/hygiene.yml index 9beba92..f9b7d73 100644 --- a/.github/workflows/hygiene.yml +++ b/.github/workflows/hygiene.yml @@ -23,6 +23,8 @@ on: - 'scripts/verify-action-pins.py' - 'scripts/verify-tag-currency.sh' - 'scripts/verify-trait-copies.py' + - 'scripts/verify-credential-guard-copies.py' + - 'src/Testing/CredentialLiterals.php' - 'scripts/verify-vcs-urls.py' concurrency: @@ -96,6 +98,16 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The same shape for the credential guard: a repository that cannot depend on + # package-tools carries a local port of CredentialLiterals, which must declare why and + # which RULES_VERSION it mirrors. notifications' copy fell two releases behind before + # this existed. simtabi is read too, for sis-sdk and lacommerce; GITHUB_TOKEN sees only + # public repositories there, so a private copy is checked by a local --from-checkout run. + - name: Every credential-guard copy declares a reason and mirrors the current rules + run: python3 scripts/verify-credential-guard-copies.py --org laranail --org simtabi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + vcs-urls: name: VCS urls runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c9fa1..f38e6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `CredentialLiterals::RULES_VERSION` (`0.1.10`), the release in which the scanner's rules last + changed. `CredentialLiteralsRulesVersionTest` pins a fingerprint of the scanner's source, so a + rule cannot change without the version moving. +- `scripts/verify-credential-guard-copies.py`, run by the scheduled hygiene job over laranail and + simtabi. Every local port of the scanner must declare + `@credential-guard-copy reason=… mirrors=…`. The script fails a copy that is undeclared, whose + reason (`php-floor-below-package-tools` or `outside-laranail`) no longer holds, or that mirrors + older rules than `RULES_VERSION`. notifications' copy had fallen behind 0.1.10 unnoticed before + this existed. + ## [0.1.11] - 2026-10-19 ### Fixed diff --git a/docs/tools/test-credentials.md b/docs/tools/test-credentials.md index c0298d7..a040f0d 100644 --- a/docs/tools/test-credentials.md +++ b/docs/tools/test-credentials.md @@ -140,6 +140,48 @@ Prefer a name that is not a credential's where the test allows it (`RFC7677_SALT `ADMIN_PASSWORD`). `exemptFiles` skips a whole file by basename. Use it sparingly: a file-wide skip also covers literals added to that file later. +## Local copies + +A repository that cannot depend on package-tools carries a port of the scanner in its own +`NoLiteralCredentialsTest.php`: +- **notifications and enumerator** support PHP 8.3, below package-tools' floor. +- **simtabi/sis-sdk and simtabi/lacommerce** stay independent of the laranail family. + +A copy declares itself in its docblock: + +```php +/** + * @credential-guard-copy reason=php-floor-below-package-tools mirrors=0.1.10 + */ +``` + +`reason` is one of two slugs, each a fact about the repository's `composer.json`: + +| Slug | Holds when | +|---|---| +| `php-floor-below-package-tools` | `require.php` admits a version below package-tools' floor | +| `outside-laranail` | the package name is not `laranail/*` | + +`mirrors` must equal `CredentialLiterals::RULES_VERSION`, the release in which the rules last +changed. `scripts/verify-credential-guard-copies.py` checks every repository in the scheduled +hygiene job. It fails a copy that is undeclared, behind, or whose reason no longer holds; +when a reason stops holding, switch that repository to the shared guard. + +`CredentialLiteralsRulesVersionTest` pins a fingerprint of the scanner's source, so changing a +rule without bumping `RULES_VERSION` fails package-tools' own suite. + +When you change a rule: +1. Bump `RULES_VERSION` to the release you are cutting. +2. Update both pins in that test. +3. Port the change into each copy, and update its `mirrors=`. + +The hygiene job's token reads only public repositories, so a private copy (sis-sdk) is checked +by running the script locally: + +```bash +python3 scripts/verify-credential-guard-copies.py --from-checkout ../ --from-checkout ../../../simtabi/packages +``` + ## Limits The guard reads PHP source only. Credentials in `phpunit.xml`, `.env.testing` or a workflow file diff --git a/scripts/verify-credential-guard-copies.py b/scripts/verify-credential-guard-copies.py new file mode 100644 index 0000000..30178fa --- /dev/null +++ b/scripts/verify-credential-guard-copies.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# +# Every repository that cannot depend on laranail/package-tools carries a local copy +# of its credential guard (`src/Testing/CredentialLiterals.php` and +# `AssertsNoLiteralCredentials`), in a test named NoLiteralCredentialsTest.php. A copy +# is a port, not a byte-for-byte duplicate, so nothing compares it with the original, +# and it falls behind silently: on 2026-10-20 laranail/notifications' copy still had +# the pre-0.1.10 rules, with no purpose check and none of the false-positive +# exclusions, while three other copies had been ported at 0.1.10. +# +# So a copy declares itself, and this script checks three things: +# +# 1. A COPY MUST DECLARE ITSELF, machine-readably, in its docblock: +# +# @credential-guard-copy reason= mirrors= +# +# A NoLiteralCredentialsTest.php that imports package-tools' +# AssertsNoLiteralCredentials is the shared guard and needs nothing. Anything else +# is a copy. A copy with no declaration is an ERROR. +# +# 2. THE REASON MUST BE ONE A MACHINE CAN FALSIFY, against the repo's composer.json: +# +# php-floor-below-package-tools -- `require.php` admits a version below +# package-tools' own floor +# outside-laranail -- the package is not laranail/*: it stays +# independent of the family +# +# An unknown slug is an error, and a reason that no longer holds is an error: the +# repo can then take the shared guard, and should. +# +# 3. THE COPY MUST MIRROR THE CURRENT RULES. `mirrors=` must equal +# CredentialLiterals::RULES_VERSION, which package-tools bumps whenever it changes a +# rule. Its fingerprint test, CredentialLiteralsRulesVersionTest, makes that bump +# impossible to forget. +# +# A run that inspects nothing fails: a source that cannot be read must not pass as +# "no copies found". +# +# Usage: +# verify-credential-guard-copies.py --org laranail --org simtabi (CI, API) +# verify-credential-guard-copies.py --from-checkout DIR [--from-checkout DIR ...] + +from __future__ import annotations + +import argparse +import importlib.util +import json +import re +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent + +# The transport and the two sources are the trait-copy script's: one implementation of +# reading the org, not two that can drift into checking different things. +_spec = importlib.util.spec_from_file_location("trait_copies", HERE / "verify-trait-copies.py") +_tc = importlib.util.module_from_spec(_spec) +assert _spec.loader is not None +_spec.loader.exec_module(_tc) + +Remote, Checkout, NotFound, Unreachable, php_floor = ( + _tc.Remote, _tc.Checkout, _tc.NotFound, _tc.Unreachable, _tc.php_floor, +) + +KNOWN_REASONS = ("php-floor-below-package-tools", "outside-laranail") +GUARD = re.compile(r"(^|/)tests/.*NoLiteralCredentialsTest\.php$") +SHARED = re.compile(r"use\s+Simtabi\\Laranail\\Package\\Tools\\Testing\\AssertsNoLiteralCredentials\s*;") +DECLARATION = re.compile(r"@credential-guard-copy\s+reason=([\w-]+)\s+mirrors=([\d.]+)") +RULES_VERSION = re.compile(r"RULES_VERSION\s*=\s*'([\d.]+)'") + + +def rules_version() -> str: + source = (HERE.parent / "src/Testing/CredentialLiterals.php").read_text(encoding="utf-8") + m = RULES_VERSION.search(source) + if not m: + sys.exit("CredentialLiterals::RULES_VERSION not found; cannot check copies.") + return m.group(1) + + +def own_floor() -> tuple[int, ...]: + floor = php_floor((HERE.parent / "composer.json").read_text(encoding="utf-8")) + if floor is None: + sys.exit("package-tools' own require.php could not be parsed.") + return floor + + +def check_reason(slug: str, manifest: str | None, floor: tuple[int, ...]) -> str | None: + """None when the reason holds; otherwise why it does not.""" + if slug == "php-floor-below-package-tools": + mine = php_floor(manifest) + if mine is None: + return "no parseable `require.php`, so the floor cannot be compared" + if mine >= floor: + return (f"floor {'.'.join(map(str, mine))} is not below package-tools' " + f"{'.'.join(map(str, floor))}: use the shared guard instead") + return None + + if slug == "outside-laranail": + try: + name = json.loads(manifest or "{}").get("name", "") + except json.JSONDecodeError: + return "composer.json is not valid JSON, so the package name cannot be read" + if name.startswith("laranail/"): + return f"the package is {name}, inside the family: use the shared guard instead" + return None + + return f"unrecognised reason -- must be one of {', '.join(KNOWN_REASONS)}" + + +def check(sources: list, version: str, floor: tuple[int, ...]) -> int: + inspected = shared = copies = 0 + unreadable: list[str] = [] + errors: list[str] = [] + + for source in sources: + label = getattr(source, "org", None) or str(getattr(source, "root", "?")) + for pkg in source.packages(): + try: + guards = [p for p in source.paths(pkg) if GUARD.search(p)] + except Unreachable as e: + unreadable.append(f"{label}/{pkg}: {e}") + continue + inspected += 1 + + for path in guards: + body = source.read(pkg, path) or "" + where = f"{label}/{pkg}/{path}" + declared = DECLARATION.search(body) + + if SHARED.search(body): + shared += 1 + if declared: + errors.append(f"{where}: uses the shared guard but still declares a copy") + continue + + copies += 1 + if not declared: + errors.append(f"{where}: a local copy with no `@credential-guard-copy reason=… mirrors=…`") + continue + + slug, mirrors = declared.groups() + problem = check_reason(slug, source.read(pkg, "composer.json"), floor) + if problem: + errors.append(f"{where}: reason={slug}: {problem}") + if mirrors != version: + errors.append(f"{where}: mirrors {mirrors}, but the rules are at {version}; port the changes") + if not errors or not errors[-1].startswith(where): + print(f" ok {where} (reason={slug}, mirrors={mirrors})") + + print(f"\nInspected {inspected} repositories: {shared} shared guard(s), {copies} local cop(ies).") + for u in unreadable: + print(f" unreadable {u}") + if inspected == 0: + print("Nothing was inspected, so this proved nothing.") + return 1 + for e in errors: + print(f" ERROR {e}") + return 1 if errors else 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--org", action="append", default=[]) + ap.add_argument("--from-checkout", metavar="DIR", action="append", default=[]) + args = ap.parse_args() + + sources = [Checkout(Path(d)) for d in args.from_checkout] + [Remote(o) for o in args.org] + if not sources: + sources = [Remote("laranail")] + + try: + return check(sources, rules_version(), own_floor()) + except Unreachable as e: + print(f"Could not read the org: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/Testing/CredentialLiterals.php b/src/Testing/CredentialLiterals.php index aa44fe2..387359b 100644 --- a/src/Testing/CredentialLiterals.php +++ b/src/Testing/CredentialLiterals.php @@ -22,6 +22,15 @@ final class CredentialLiterals */ public const string EXEMPTION = '@credential-literal-exempt'; + /** + * The package-tools release in which these rules last changed. A repository that cannot + * depend on package-tools ports them into a local copy, which declares the version it + * mirrors (`@credential-guard-copy reason=… mirrors=…`); scripts/verify-credential-guard-copies.py + * fails any copy behind this. Changing a rule without bumping this fails + * CredentialLiteralsRulesVersionTest, so the copies cannot be left behind silently. + */ + public const string RULES_VERSION = '0.1.10'; + /** * Every literal credential in $source, as [byte offset, rule, literal]. * diff --git a/tests/Feature/CredentialLiteralsRulesVersionTest.php b/tests/Feature/CredentialLiteralsRulesVersionTest.php new file mode 100644 index 0000000..0e8ab88 --- /dev/null +++ b/tests/Feature/CredentialLiteralsRulesVersionTest.php @@ -0,0 +1,25 @@ +toBe('0.1.10') + ->and($fingerprint)->toBe('eeabe337494d7e05cd53b29388bafc096dc145d33f579dd9bf10aa579f960658'); +}); diff --git a/tests/Feature/VerifyCredentialGuardCopiesScriptTest.php b/tests/Feature/VerifyCredentialGuardCopiesScriptTest.php new file mode 100644 index 0000000..cca1219 --- /dev/null +++ b/tests/Feature/VerifyCredentialGuardCopiesScriptTest.php @@ -0,0 +1,77 @@ + [$composer, $guard]) { + @mkdir("{$root}/{$name}/.git", 0777, true); + @mkdir("{$root}/{$name}/tests/Architecture", 0777, true); + file_put_contents("{$root}/{$name}/composer.json", json_encode($composer)); + file_put_contents("{$root}/{$name}/tests/Architecture/NoLiteralCredentialsTest.php", $guard); + } + + return $root; +} + +function runGuardCopies(array $repos): Process +{ + $process = new Process(['python3', dirname(__DIR__, 2) . '/scripts/verify-credential-guard-copies.py', '--from-checkout', guardCopiesTree($repos)]); + $process->run(); + + return $process; +} + +function copyDeclaring(string $reason, string $mirrors): string +{ + return " [['name' => 'laranail/shared', 'require' => ['php' => '^8.4.1']], " [['name' => 'laranail/old-php', 'require' => ['php' => '^8.3']], copyDeclaring('php-floor-below-package-tools', CredentialLiterals::RULES_VERSION)], + 'outsider' => [['name' => 'simtabi/outsider', 'require' => ['php' => '^8.5']], copyDeclaring('outside-laranail', CredentialLiterals::RULES_VERSION)], + ]); + + expect($process->getExitCode())->toBe(0, $process->getOutput()) + ->and($process->getOutput())->toContain('Inspected 3 repositories: 1 shared guard(s), 2 local cop(ies).'); +}); + +it('fails an undeclared copy, a stale copy and a reason that no longer holds', function (): void { + $process = runGuardCopies([ + 'undeclared' => [['name' => 'laranail/undeclared', 'require' => ['php' => '^8.3']], " [['name' => 'laranail/stale', 'require' => ['php' => '^8.3']], copyDeclaring('php-floor-below-package-tools', '0.1.0')], + 'expired' => [['name' => 'laranail/expired', 'require' => ['php' => '^8.4.1 || ^8.5']], copyDeclaring('php-floor-below-package-tools', CredentialLiterals::RULES_VERSION)], + 'insider' => [['name' => 'laranail/insider', 'require' => ['php' => '^8.5']], copyDeclaring('outside-laranail', CredentialLiterals::RULES_VERSION)], + ]); + + expect($process->getExitCode())->toBe(1) + ->and($process->getOutput()) + ->toContain('undeclared/tests/Architecture/NoLiteralCredentialsTest.php: a local copy with no') + ->toContain('stale/tests/Architecture/NoLiteralCredentialsTest.php: mirrors 0.1.0') + ->toContain('expired/tests/Architecture/NoLiteralCredentialsTest.php: reason=php-floor-below-package-tools: floor 8.4.1 is not below') + ->toContain('insider/tests/Architecture/NoLiteralCredentialsTest.php: reason=outside-laranail: the package is laranail/insider'); +}); + +it('refuses to pass when it inspected nothing', function (): void { + $root = sys_get_temp_dir() . '/guard-copies-empty-' . bin2hex(random_bytes(5)); + mkdir($root); + + $process = new Process(['python3', dirname(__DIR__, 2) . '/scripts/verify-credential-guard-copies.py', '--from-checkout', $root]); + $process->run(); + + expect($process->getExitCode())->toBe(1) + ->and($process->getOutput())->toContain('Nothing was inspected'); +});