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
12 changes: 12 additions & 0 deletions .github/workflows/hygiene.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions docs/tools/test-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions scripts/verify-credential-guard-copies.py
Original file line number Diff line number Diff line change
@@ -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=<slug> mirrors=<version>
#
# 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())
9 changes: 9 additions & 0 deletions src/Testing/CredentialLiterals.php
Original file line number Diff line number Diff line change
Expand Up @@ -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].
*
Expand Down
25 changes: 25 additions & 0 deletions tests/Feature/CredentialLiteralsRulesVersionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

use Simtabi\Laranail\Package\Tools\Testing\CredentialLiterals;

/*
* The scanner's rules are ported into local copies by repositories that cannot depend on
* package-tools (notifications, enumerator, simtabi/sis-sdk, simtabi/lacommerce). Each copy
* declares the RULES_VERSION it mirrors, and scripts/verify-credential-guard-copies.py fails a
* copy that is behind. That only works if RULES_VERSION moves when a rule does, so this pins
* the source the version describes.
*
* If this fails:
* - you changed a rule: set RULES_VERSION to the release you are cutting, update both pins
* below, and port the change into every local copy;
* - you changed nothing a copy needs (a comment, formatting): update the fingerprint only.
*/
it('moves RULES_VERSION whenever the scanner source changes', function (): void {
$source = (string) file_get_contents(dirname(__DIR__, 2) . '/src/Testing/CredentialLiterals.php');
$fingerprint = hash('sha256', (string) preg_replace('/^.*RULES_VERSION = .*\n/m', '', $source));

expect(CredentialLiterals::RULES_VERSION)->toBe('0.1.10')
->and($fingerprint)->toBe('eeabe337494d7e05cd53b29388bafc096dc145d33f579dd9bf10aa579f960658');
});
77 changes: 77 additions & 0 deletions tests/Feature/VerifyCredentialGuardCopiesScriptTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

use Symfony\Component\Process\Process;
use Simtabi\Laranail\Package\Tools\Testing\CredentialLiterals;

/*
* scripts/verify-credential-guard-copies.py against a throwaway `packages/` tree, so its rules
* are pinned without the network: a shared guard passes, a declared copy whose reason holds and
* whose mirrored version is current passes, and an undeclared copy, a stale copy or a reason that
* no longer holds each fail.
*/
function guardCopiesTree(array $repos): string
{
$root = sys_get_temp_dir() . '/guard-copies-' . bin2hex(random_bytes(5));

foreach ($repos as $name => [$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 "<?php\n/**\n * @credential-guard-copy reason={$reason} mirrors={$mirrors}\n */\n";
}

it('passes the shared guard and a current, justified copy', function (): void {
$process = runGuardCopies([
'shared' => [['name' => 'laranail/shared', 'require' => ['php' => '^8.4.1']], "<?php\nuse Simtabi\\Laranail\\Package\\Tools\\Testing\\AssertsNoLiteralCredentials;\n"],
'old-php' => [['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']], "<?php\n// a hand-rolled scanner\n"],
'stale' => [['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');
});
Loading