From 802c529486cceea87428ec84fbda23d0e07c022b Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Fri, 7 Aug 2026 00:23:20 -0400 Subject: [PATCH] fix(security): audit require-dev dependencies too (S246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo's CI had no security audit gate at all, so no published advisory against a locked dependency could ever fail it. On 2026-08-06 CVE-2026-67434 (HIGH, OS command injection, GHSA-hmqg-cxww-wqhq) landed against squizlabs/php_codesniffer and only phlix-server went red — the one repo that audited its development dependencies. Sibling repos that did have a gate ran `composer audit --no-dev`, which drops every require-dev package and so could not have caught it either. New `scripts/security-audit-check.php` audits the WHOLE lock via `composer audit --locked --format=json`, labels each finding [require] or [require-dev], reports abandoned packages and config-ignored advisories as loud but non-blocking, and prints the corpus it examined. No baseline file and no ignore list in the script: the only escape hatch is `config.audit.ignore` in composer.json, which the gate reports back as a loud IGNORED notice. Corpus measured on the committed lock: 33 locked packages (5 require, 28 require-dev). Floors MIN_AUDITED_PACKAGES=28 and MIN_AUDITED_DEV_PACKAGES=24; the dev floor is the direct anti-regression, so a returning --no-dev reads as a failure rather than a clean audit of a fraction of the lock. The gate is blocking: a new composer-audit job on push and pull_request, last in .github/workflows/test.yml, with no continue-on-error and no if: condition. 26 guard tests in tests/SecurityAuditCheckTest.php pin all of that shut by execution. Co-Authored-By: Claude Opus 5 --- .github/workflows/test.yml | 36 ++ scripts/security-audit-check.php | 802 +++++++++++++++++++++++++++++++ tests/SecurityAuditCheckTest.php | 775 +++++++++++++++++++++++++++++ 3 files changed, 1613 insertions(+) create mode 100644 scripts/security-audit-check.php create mode 100644 tests/SecurityAuditCheckTest.php diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c77f42..ef5f5cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,3 +42,39 @@ jobs: - name: Run PHPCS run: vendor/bin/phpcs src --standard=PSR12 + + # S246 — this repository had NO security audit gate at all. On 2026-08-06 a + # HIGH advisory (CVE-2026-67434, OS command injection, GHSA-hmqg-cxww-wqhq) + # landed against squizlabs/php_codesniffer and nothing here would ever have + # said so. The sibling repos that DID have a gate ran `composer audit --no-dev`, + # which drops every development dependency from the audited set and so could + # not fail on it either. + # + # This job audits the WHOLE lock — runtime and development — and prints the + # corpus it examined. Read scripts/security-audit-check.php for the policy and + # its reasoning. + # + # Do NOT add a development-dependency exclusion, do NOT add continue-on-error + # to the step below, and do NOT wrap it in a conditional: the guard test + # tests/SecurityAuditCheckTest.php parses this file and fails on each of those edits. + # + # Keep this job LAST in the file — that guard test slices the workflow from + # `composer-audit:` to the end and asserts the slice is not neutered. + composer-audit: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 + with: + php-version: '8.3' + tools: composer:v2 + + # `--locked` audits composer.lock and needs no vendor/, so there is + # deliberately no `composer install` step here. + - name: Run Security Audit (runtime AND development dependencies) + run: php scripts/security-audit-check.php diff --git a/scripts/security-audit-check.php b/scripts/security-audit-check.php new file mode 100644 index 0000000..4b947db --- /dev/null +++ b/scripts/security-audit-check.php @@ -0,0 +1,802 @@ +=4.0.0,<4.0.2`. Across the estate only phlix-server went red, + * because only phlix-server audited its development dependencies. Everywhere + * else the advisory was simply invisible, and "invisible" is indistinguishable + * from "clean". + * + * A gate that silently covers half its subject is worse than no gate, because it + * is read as evidence. + * + * ## The policy: audit EVERYTHING, and block on everything + * + * The two honest shapes were (a) audit everything and fix promptly, or (b) audit + * runtime dependencies as blocking and development dependencies as a separate, + * clearly-labelled non-blocking report. **This gate implements (a)**, the same + * policy phlix-hub adopted under S246 (PR #217): + * + * 1. **A dev dependency is not a safe dependency.** The advisory that exposed + * this hole is command injection in a linter that CI runs, in a checkout, + * against pull-request-authored content. The developer workstation and the + * CI runner are exactly the machines that hold the signing keys and the + * deploy credentials. "It does not ship" is not the same as "it cannot hurt + * you". + * 2. **Every advisory here is labelled with its scope.** The report prints + * `[require]` or `[require-dev]` against each affected package, so a reader + * can tell in one glance whether production is exposed or only the toolchain + * is. Option (b) buys that same information at the price of a second, + * non-blocking channel that nobody reads. + * 3. **There is a recorded escape hatch.** An advisory that genuinely cannot be + * actioned is acknowledged in `composer.json` under `config.audit.ignore` + * with a written reason, which this script then reports as a loud IGNORED + * notice. That is an explicit, reviewable, in-repo decision — the opposite + * of a flag that quietly excludes most of the lock. + * + * There is deliberately **no baseline file and no ignore list in this script**. + * The only way to pass an advisory is to fix it or to acknowledge it in + * `composer.json`, where it is committed, diffed and reviewed. + * + * ## Blocking vs advisory + * + * `composer audit` returns one exit code for several very different findings, so + * the verdict here is computed from `--format=json` rather than inherited from + * `$?`: + * + * | finding | verdict | + * | ----------------------------- | ----------------------------------------- | + * | security advisory (any scope) | **BLOCKING** — exit 1 | + * | abandoned package | ADVISORY — loud `::warning::`, exit 0 | + * | advisory ignored via config | ADVISORY — loud `::notice::`, exit 0 | + * | unreachable advisory repo | **BLOCKING** — exit 1 (audited nothing) | + * | missing / unparseable JSON | **BLOCKING** — exit 1 | + * | corpus below its floor | **BLOCKING** — exit 1 (audited too little)| + * | composer absent or < 2.4 | **BLOCKING** — exit 1 | + * + * Abandonment is not a vulnerability and is usually unfixable from this repo, so + * it warns rather than blocks: a gate that goes red for a reason nobody here can + * act on gets switched off, and that is precisely how blind gates come to exist. + * The unreachable-repository row is the same rule as the corpus floor — a gate + * that could not measure must fail, never report success. + * + * ## The corpus, and why it is printed + * + * `composer audit` never says how many packages it looked at, and "inspected + * zero files" is the commonest false pass in this estate: it looks exactly like + * a clean run. So this script counts the audited set out of `composer.lock` + * itself and prints it — total, `require`, and `require-dev` — then refuses to + * pass if either the total or the dev half falls below its floor. The dev floor + * is the specific anti-regression for S246: if `--no-dev` ever appears, or + * `packages-dev` is emptied, the corpus line says so out loud and the gate fails + * instead of reporting a clean audit of a fraction of the lock. + * + * Usage: + * php scripts/security-audit-check.php # runs composer audit itself + * php scripts/security-audit-check.php audit.json # reads a captured payload + * php scripts/security-audit-check.php audit.json other.lock # ...against another lock + * + * The optional arguments exist so the guard tests can exercise every verdict + * offline, without a network round-trip to Packagist. + * + * Environment: + * COMPOSER_BIN path to the composer binary (default: `composer` on PATH) + * + * Exit codes: 0 = no blocking finding. 1 = a security advisory was found, OR the + * audit could not run over a credible corpus (which is a failure, not a skip). + * + * @package Phlix\PluginExample + * @copyright 2026 Joe Huss + * @license MIT + */ + +declare(strict_types=1); + +/** `composer audit` was added in Composer 2.4; older is unusable. */ +const MIN_COMPOSER_VERSION = '2.4.0'; + +/** + * The composer arguments the audit is invoked with. + * + * Declared as a constant so {@see \Phlix\PluginExample\Tests\SecurityAuditCheckTest} can assert the list directly + * instead of pattern-matching prose — in particular that `--no-dev` is **not** + * present and `--locked` **is**. + * + * `--locked` audits `composer.lock` and needs no `vendor/` at all. That matters: + * the lock is the committed artifact a pull request actually changes, and + * Composer 2.10 refuses to *install* packages carrying known advisories + * (`policy.advisories.block`), so auditing after `composer install` would mean a + * vulnerable lock died in the solver with an opaque resolution error before the + * audit ever ran. + */ +const AUDIT_ARGUMENTS = ['audit', '--locked', '--format=json', '--no-interaction']; + +/** + * Floor for the total number of locked packages the audit covers. + * + * Measured on this repository's committed lock 2026-08-06: 33 packages + * (5 require + 28 require-dev). The floor sits below that so ordinary + * dependency pruning does not trip it, while a gutted or half-read lock does. + * Lowering it is how this gate would be neutered, so a lower value deserves the + * same scrutiny as deleting the check. + */ +const MIN_AUDITED_PACKAGES = 28; + +/** + * Floor for the `require-dev` half of the corpus. + * + * This is the direct anti-regression for S246. `--no-dev` leaves the total + * looking plausible while silently dropping most of the packages, and a clean + * audit of a truncated corpus is indistinguishable from a clean audit. Measured + * on this repository's committed lock: 28 dev packages. + */ +const MIN_AUDITED_DEV_PACKAGES = 24; + +/** + * Emit a GitHub Actions error annotation and stop. + * + * Annotations go to STDOUT because that is the stream the runner scans for + * workflow commands. + */ +function fail(string $headline, string ...$detail): never +{ + fwrite(STDOUT, '::error::' . $headline . "\n"); + + foreach ($detail as $line) { + fwrite(STDOUT, ' ' . $line . "\n"); + } + + exit(1); +} + +/** + * Emit a non-blocking annotation. The whole point of this script is that these + * are VISIBLE rather than swallowed, so they are real workflow commands and not + * a bare echo. + * + * @param 'notice'|'warning' $level + */ +function annotate(string $level, string $headline, string ...$detail): void +{ + fwrite(STDOUT, '::' . $level . '::' . $headline . "\n"); + + foreach ($detail as $line) { + fwrite(STDOUT, ' ' . $line . "\n"); + } +} + +/** + * Run a command without a shell and capture both streams separately. + * + * @param list $command + * + * @return array{stdout: string, stderr: string, exit: int} + */ +function runProcess(array $command): array +{ + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $pipes = []; + $process = @proc_open($command, $descriptors, $pipes); + + if (!is_resource($process)) { + return ['stdout' => '', 'stderr' => 'proc_open() failed', 'exit' => 127]; + } + + fclose($pipes[0]); + + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + + return ['stdout' => $stdout, 'stderr' => $stderr, 'exit' => proc_close($process)]; +} + +/** + * Read the audited corpus out of `composer.lock`. + * + * `composer audit --locked` audits exactly `packages` + `packages-dev`, so this + * is the set the verdict below is a statement about. An unreadable lock is a + * failed audit, not an empty one. + * + * @return array{runtime: array, dev: array} + */ +function readCorpus(string $lockPath): array +{ + if (!is_file($lockPath)) { + fail( + sprintf('composer.lock not found at "%s" — there is nothing to audit.', $lockPath), + 'The audit is over the committed lock; without it the gate has measured nothing.', + ); + } + + $raw = (string) file_get_contents($lockPath); + + if (trim($raw) === '') { + fail(sprintf('composer.lock at "%s" is empty.', $lockPath)); + } + + try { + /** @var mixed $decoded */ + $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + fail( + sprintf('composer.lock at "%s" is not parseable JSON.', $lockPath), + 'json_decode: ' . $e->getMessage(), + ); + } + + if (!is_array($decoded) || !array_key_exists('packages', $decoded)) { + fail( + sprintf('composer.lock at "%s" has no "packages" key — this is not a composer lock.', $lockPath), + 'Failing rather than assuming an empty corpus.', + ); + } + + /** @var array $decoded */ + return [ + 'runtime' => collectPackages($decoded['packages'] ?? []), + 'dev' => collectPackages($decoded['packages-dev'] ?? []), + ]; +} + +/** + * Reduce a lock section to a name => version map. + * + * @return array + */ +function collectPackages(mixed $section): array +{ + if (!is_array($section)) { + return []; + } + + $packages = []; + + foreach (array_filter($section, 'is_array') as $package) { + $name = stringField($package, 'name'); + + if ($name === '') { + continue; + } + + $packages[$name] = stringField($package, 'version', 'unknown'); + } + + return $packages; +} + +/** + * Read a string field out of a decoded JSON structure, or fall back. + * + * Every payload this script reads is `mixed` all the way down, so the narrowing + * is centralised here rather than repeated as `is_string($x['k'] ?? null)` + * ternaries that re-read the offset. + * + * @param array $data + */ +function stringField(array $data, string $key, string $default = ''): string +{ + return is_string($data[$key] ?? null) ? (string) $data[$key] : $default; +} + +/** + * Render an arbitrary decoded value as something printable. + */ +function stringify(mixed $value): string +{ + if (is_string($value)) { + return $value; + } + + if (is_scalar($value)) { + return (string) $value; + } + + $encoded = json_encode($value); + + return is_string($encoded) ? $encoded : ''; +} + +/** + * Coerce a decoded JSON value to a list of printable strings. + * + * @return list + */ +function toStringList(mixed $value): array +{ + if (!is_array($value)) { + return []; + } + + return array_values(array_map(stringify(...), $value)); +} + +/** + * Coerce a decoded JSON object to a string => string map. + * + * @return array + */ +function toStringMap(mixed $value): array +{ + if (!is_array($value)) { + return []; + } + + $map = []; + + foreach (array_keys($value) as $key) { + $map[(string) $key] = stringify($value[$key]); + } + + return $map; +} + +/** + * Print the corpus and refuse to continue if it is too small to be believed. + * + * "The gate ran and inspected zero packages" is the commonest false pass in this + * estate and looks exactly like a clean run, so the size is stated out loud and + * checked, not assumed. + * + * @param array{runtime: array, dev: array} $corpus + */ +function reportAndCheckCorpus(array $corpus, string $lockPath): void +{ + $runtime = count($corpus['runtime']); + $dev = count($corpus['dev']); + $total = $runtime + $dev; + + fwrite(STDOUT, sprintf( + "Audit corpus: %d locked package(s) — %d require, %d require-dev (%s).\n", + $total, + $runtime, + $dev, + $lockPath, + )); + + if ($total < MIN_AUDITED_PACKAGES) { + fail( + sprintf( + 'Audit corpus is %d package(s), below the floor of %d — the audit covered too little to mean anything.', + $total, + MIN_AUDITED_PACKAGES, + ), + 'A clean audit of a truncated corpus is indistinguishable from a clean audit.', + 'Check that composer.lock is the real, committed lock.', + ); + } + + if ($dev < MIN_AUDITED_DEV_PACKAGES) { + fail( + sprintf( + 'Only %d require-dev package(s) in the corpus, below the floor of %d.', + $dev, + MIN_AUDITED_DEV_PACKAGES, + ), + 'S246: this gate exists because a development-dependency exclusion silently hid a HIGH', + 'advisory against squizlabs/php_codesniffer from every repository but one.', + 'Do NOT restore --no-dev and do NOT lower this floor to make the gate pass.', + ); + } +} + +/** + * Resolve the composer binary and prove it can audit, or die trying. + * + * The tool being absent must fail LOUDLY. It must never degrade back into + * `if [ -f ... ]; then ... fi`, which is how a sibling repo's audit step spent + * its entire life green having run nothing. + */ +function assertComposerCanAudit(string $composerBin): string +{ + $probe = runProcess([$composerBin, '--version', '--no-interaction']); + + if ($probe['exit'] !== 0 || $probe['stdout'] === '') { + fail( + sprintf('Cannot run "%s --version" — the security audit tool is not available.', $composerBin), + 'composer must be on PATH (setup-php provides it) or COMPOSER_BIN must point at it.', + sprintf( + 'exit=%d stderr=%s', + $probe['exit'], + trim($probe['stderr']) !== '' ? trim($probe['stderr']) : '', + ), + 'Do NOT wrap the audit in a conditional that skips when the tool is absent.', + ); + } + + if (preg_match('/Composer(?:\s+version)?\s+(\d+\.\d+\.\d+)/i', $probe['stdout'], $matches) !== 1) { + fail( + sprintf('Could not parse a version out of "%s --version".', $composerBin), + 'Output was: ' . trim($probe['stdout']), + ); + } + + $version = $matches[1]; + + if (version_compare($version, MIN_COMPOSER_VERSION, '<')) { + fail( + sprintf('Composer %s is too old — `composer audit` was added in %s.', $version, MIN_COMPOSER_VERSION), + 'Pin a newer composer in the setup-php step (tools: composer:v2).', + ); + } + + fwrite(STDOUT, sprintf("Auditing with composer %s (%s)\n", $version, $composerBin)); + + return $composerBin; +} + +/** + * Ask composer for the audit payload. + * + * The exit code is deliberately NOT used as the verdict: it folds abandoned + * packages in with real advisories. It is only reported when the payload fails + * to parse, where it helps explain why. + */ +function captureAuditPayload(string $composerBin): string +{ + $result = runProcess(array_merge([$composerBin], AUDIT_ARGUMENTS)); + + if (trim($result['stdout']) === '') { + fail( + 'composer audit produced no output — the audit did not run.', + sprintf('exit=%d', $result['exit']), + 'stderr: ' . (trim($result['stderr']) !== '' ? trim($result['stderr']) : ''), + 'A common cause is a missing or stale composer.lock (--locked needs one).', + ); + } + + return $result['stdout']; +} + +/** + * Decode the payload, or fail. An unreadable audit is a failed audit. + * + * @return array + */ +function decodePayload(string $raw, string $origin): array +{ + try { + /** @var mixed $decoded */ + $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + fail( + sprintf('Audit payload from %s is not parseable JSON.', $origin), + 'json_decode: ' . $e->getMessage(), + 'First 200 bytes: ' . substr(trim($raw), 0, 200), + ); + } + + if (!is_array($decoded)) { + fail(sprintf('Audit payload from %s is not a JSON object.', $origin)); + } + + if (!array_key_exists('advisories', $decoded)) { + fail( + sprintf('Audit payload from %s has no "advisories" key.', $origin), + 'composer audit --format=json always emits one, so this is not a composer audit', + 'payload and the gate cannot read it. Failing rather than assuming "no advisories".', + ); + } + + /** @var array $decoded */ + return $decoded; +} + +/** + * `composer audit --format=json` emits `[]` for an empty advisory set and a + * package-keyed object when populated. Normalise both to a map. + * + * @return array>> + */ +function normaliseAdvisoryMap(mixed $value): array +{ + if (!is_array($value)) { + return []; + } + + $map = []; + + foreach ($value as $package => $entries) { + if (!is_array($entries)) { + continue; + } + + $list = []; + + foreach (array_filter($entries, 'is_array') as $entry) { + $list[] = $entry; + } + + $map[(string) $package] = $list; + } + + return $map; +} + +/** + * Where in the lock a package sits, for the scope label on each finding. + * + * This is the information a "dev findings go to a separate report" policy would + * have bought with a second reporting channel: a reader sees immediately whether + * production ships the affected package or whether only the toolchain is + * exposed. + * + * @param array{runtime: array, dev: array} $corpus + */ +function scopeOf(string $package, array $corpus): string +{ + if (array_key_exists($package, $corpus['runtime'])) { + return 'require'; + } + + if (array_key_exists($package, $corpus['dev'])) { + return 'require-dev'; + } + + return 'not in lock'; +} + +/** + * @param array $advisory + */ +function describeAdvisory(array $advisory): string +{ + $severity = stringField($advisory, 'severity'); + $cve = stringField($advisory, 'cve'); + $id = stringField($advisory, 'advisoryId'); + + return sprintf( + '[%s] %s — %s', + $severity !== '' ? strtoupper($severity) : 'UNKNOWN', + $cve !== '' ? $cve : ($id !== '' ? $id : 'unidentified'), + stringField($advisory, 'title', '(no title)'), + ); +} + +/** + * @param array>> $advisories + * @param array{runtime: array, dev: array} $corpus + * + * @return list + */ +function renderAdvisoryLines(array $advisories, array $corpus): array +{ + $lines = []; + + foreach ($advisories as $package => $entries) { + $affected = ''; + + foreach ($entries as $entry) { + $candidate = stringField($entry, 'affectedVersions'); + + if ($candidate !== '') { + $affected = $candidate; + + break; + } + } + + $locked = $corpus['runtime'][$package] ?? $corpus['dev'][$package] ?? ''; + $lines[] = sprintf( + '%s [%s]%s%s', + $package, + scopeOf($package, $corpus), + $locked !== '' ? ' locked at ' . $locked : '', + $affected !== '' ? ' (affected: ' . $affected . ')' : '', + ); + + foreach ($entries as $entry) { + $lines[] = ' ' . describeAdvisory($entry); + $link = stringField($entry, 'link'); + + if ($link !== '') { + $lines[] = ' ' . $link; + } + } + } + + return $lines; +} + +/** + * @param array>> $advisories + */ +function countAdvisories(array $advisories): int +{ + $total = 0; + + foreach ($advisories as $entries) { + $total += count($entries); + } + + return $total; +} + +/** + * @param array>> $advisories + * @param array{runtime: array, dev: array} $corpus + * + * @return array{require: int, 'require-dev': int, 'not in lock': int} + */ +function countByScope(array $advisories, array $corpus): array +{ + $require = 0; + $dev = 0; + $unknown = 0; + + foreach (array_keys($advisories) as $package) { + $scope = scopeOf($package, $corpus); + + if ($scope === 'require') { + ++$require; + } elseif ($scope === 'require-dev') { + ++$dev; + } else { + ++$unknown; + } + } + + return ['require' => $require, 'require-dev' => $dev, 'not in lock' => $unknown]; +} + +// --------------------------------------------------------------------------- +// The corpus comes first: state what is being audited before saying anything +// about it. +// --------------------------------------------------------------------------- + +$lockPath = $argv[2] ?? (dirname(__DIR__) . '/composer.lock'); +$corpus = readCorpus($lockPath); + +reportAndCheckCorpus($corpus, $lockPath); + +// --------------------------------------------------------------------------- +// Acquire the payload — either from a file (tests) or from composer (CI). +// --------------------------------------------------------------------------- + +$payloadPath = $argv[1] ?? null; + +if (is_string($payloadPath) && $payloadPath !== '') { + if (!is_file($payloadPath)) { + fail(sprintf('Audit payload "%s" does not exist.', $payloadPath)); + } + + $raw = (string) file_get_contents($payloadPath); + $origin = $payloadPath; + + if (trim($raw) === '') { + fail(sprintf('Audit payload "%s" is empty.', $payloadPath)); + } +} else { + $composerBin = getenv('COMPOSER_BIN'); + + if (!is_string($composerBin) || trim($composerBin) === '') { + $composerBin = 'composer'; + } + + $raw = captureAuditPayload(assertComposerCanAudit(trim($composerBin))); + $origin = 'composer ' . implode(' ', AUDIT_ARGUMENTS); +} + +$payload = decodePayload($raw, $origin); + +// --------------------------------------------------------------------------- +// Guard — an audit that could not reach its advisory source measured NOTHING. +// +// Same rule as the corpus floor: cannot-measure must fail, never pass. +// --------------------------------------------------------------------------- + +$unreachable = toStringList($payload['unreachable-repositories'] ?? []); + +if ($unreachable !== []) { + $names = []; + + foreach ($unreachable as $repo) { + $names[] = ' ' . $repo; + } + + fail( + sprintf('%d advisory repository/ies were unreachable — the audit measured nothing.', count($names)), + ...$names, + ); +} + +// --------------------------------------------------------------------------- +// Advisory-only findings. LOUD, but they do not block. +// --------------------------------------------------------------------------- + +$ignored = normaliseAdvisoryMap($payload['ignored-advisories'] ?? []); + +if ($ignored !== []) { + annotate( + 'notice', + sprintf( + '%d security advisory/ies affecting %d package(s) are IGNORED by composer config — acknowledged, not fixed.', + countAdvisories($ignored), + count($ignored), + ), + ...renderAdvisoryLines($ignored, $corpus), + ); +} + +$abandoned = toStringMap($payload['abandoned'] ?? []); + +if ($abandoned !== []) { + $lines = []; + + foreach ($abandoned as $package => $replacement) { + $lines[] = sprintf( + '%s [%s] — %s', + $package, + scopeOf($package, $corpus), + $replacement !== '' + ? 'replaced by ' . $replacement + : 'no replacement suggested', + ); + } + + annotate( + 'warning', + sprintf('%d abandoned package(s). ADVISORY ONLY — this does NOT fail the build.', count($lines)), + ...array_merge($lines, [ + 'Abandonment is not a vulnerability, and these are usually transitive dependencies', + 'that cannot be fixed from this repo. Blocking on them would make every pull request', + 'red for a reason nobody can act on, and a gate that is red for unrelated reasons', + 'gets switched off — which is how a blind gate comes to exist in the first place.', + ]), + ); +} + +// --------------------------------------------------------------------------- +// The blocking verdict — every scope, including require-dev. That is the whole +// point of S246. +// --------------------------------------------------------------------------- + +$advisories = normaliseAdvisoryMap($payload['advisories']); + +if ($advisories !== []) { + $byScope = countByScope($advisories, $corpus); + + fail( + sprintf( + 'Security audit FAILED — %d advisory/ies affecting %d package(s) (%d require, %d require-dev).', + countAdvisories($advisories), + count($advisories), + $byScope['require'], + $byScope['require-dev'], + ), + ...array_merge(renderAdvisoryLines($advisories, $corpus), [ + 'Update the affected package(s). A require-dev advisory blocks too: the toolchain', + 'runs on the machines holding the deploy credentials, so "it does not ship" is not', + '"it cannot hurt you".', + 'If an advisory genuinely cannot be actioned, acknowledge it explicitly under', + 'config.audit.ignore in composer.json — with a written reason — so it is recorded in', + 'the repo and reported above as IGNORED. Do not disable this gate and do not restore', + '--no-dev.', + ]), + ); +} + +fwrite(STDOUT, sprintf( + "No security advisories affecting the %d locked package(s) audited.\n", + count($corpus['runtime']) + count($corpus['dev']), +)); +fwrite(STDOUT, "Security audit passed.\n"); + +exit(0); diff --git a/tests/SecurityAuditCheckTest.php b/tests/SecurityAuditCheckTest.php new file mode 100644 index 0000000..68f8626 --- /dev/null +++ b/tests/SecurityAuditCheckTest.php @@ -0,0 +1,775 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Phlix\PluginExample\Tests; + +use JsonException; +use PHPUnit\Framework\TestCase; + +/** + * S246 — behaviour of `scripts/security-audit-check.php`, by execution. + * + * ## The defect these tests pin shut + * + * This repository had **no security audit gate at all**: nothing in CI ever + * asked whether a locked dependency carried a published advisory. + * + * On 2026-08-06 CVE-2026-67434 (HIGH, OS command injection) landed against + * `squizlabs/php_codesniffer`. Only phlix-server went red, because only + * phlix-server audited its development dependencies. A green that cannot go red + * is not evidence. + * + * ## What is asserted, and why each half exists + * + * 1. **The dev half is really audited.** {@see testAnAdvisoryAgainstADevelopmentDependencyBlocks()} + * is the direct regression test: a `require-dev` advisory must exit 1 and be + * labelled `[require-dev]` so the reader can still see the scope. + * 2. **`--no-dev` cannot appear.** The audit flags are a declared constant, + * read here rather than pattern-matched out of prose, and the workflow is + * parsed with its comments stripped — the new job carries a comment naming + * the offending flag, and a detector that matches its own documentation is + * not a detector. + * 3. **The gate cannot be neutered.** The audit job must carry no + * `continue-on-error` and no `if:` condition, and the workflow must still + * run on `pull_request`. + * 4. **The corpus is stated and floored.** A gate that ran and inspected zero + * packages is the commonest false pass in this estate and looks exactly like + * a clean run, so the printed size is checked against an independent count, + * and both floors are driven to failure. + * 5. **Cannot-measure fails.** Missing lock, unparseable lock, missing payload, + * empty payload, unparseable payload, unrecognised payload shape and an + * unreachable advisory repository each exit 1 rather than passing. + * 6. **The blocking/advisory split holds.** Abandonment and config-ignored + * advisories are loud but non-blocking; a real advisory blocks even when + * they are present, so the advisory half cannot become decorative. + * + * @internal + */ +final class SecurityAuditCheckTest extends TestCase +{ + private const SCRIPT = __DIR__ . '/../scripts/security-audit-check.php'; + + private const WORKFLOW = __DIR__ . '/../.github/workflows/test.yml'; + + private const REAL_LOCK = __DIR__ . '/../composer.lock'; + + /** + * Floors the script enforces, restated here deliberately. + * + * Measured on this repository's committed lock 2026-08-06: 33 packages + * (5 require, 28 require-dev). {@see testFloorsMatchTheScript()} keeps + * the two copies honest, so lowering the floor in the script alone reddens + * this suite instead of quietly shrinking the gate. + */ + private const MIN_PACKAGES = 28; + + private const MIN_DEV_PACKAGES = 24; + + private string $workDir = ''; + + protected function setUp(): void + { + $dir = sys_get_temp_dir() . '/s246-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir($dir, 0o700, true), 'temp dir for the audit fixtures'); + $this->workDir = $dir; + } + + protected function tearDown(): void + { + if ($this->workDir === '') { + return; + } + + foreach ((array) glob($this->workDir . '/*') as $file) { + if (is_string($file) && is_file($file)) { + unlink($file); + } + } + + if (is_dir($this->workDir)) { + rmdir($this->workDir); + } + + $this->workDir = ''; + + parent::tearDown(); + } + + // ----------------------------------------------------------------------- + // The gate exists and is wired into CI. + // ----------------------------------------------------------------------- + + public function testTheGateScriptExists(): void + { + self::assertFileExists(self::SCRIPT); + } + + public function testTheWorkflowRunsTheGateScript(): void + { + self::assertStringContainsString( + 'php scripts/security-audit-check.php', + $this->workflowWithoutComments(), + 'test.yml must invoke the audit gate.', + ); + } + + /** + * A gate that only runs on demand is not a gate. The workflow must fire on + * pull requests, which is the event the audit is meant to block. + */ + public function testTheWorkflowRunsOnPullRequests(): void + { + self::assertMatchesRegularExpression( + '/^\s*pull_request:?\s*$/m', + $this->workflowWithoutComments(), + 'The audit must run on pull requests, not only on demand.', + ); + } + + /** + * The whole defect in one line. The workflow is read with comments removed + * because the new job carries a comment explaining what `--no-dev` did, and + * a check that matches its own documentation proves nothing. + */ + public function testTheWorkflowDoesNotExcludeDevelopmentDependencies(): void + { + $yaml = $this->workflowWithoutComments(); + + self::assertStringNotContainsString( + '--no-dev', + $yaml, + 'S246: excluding require-dev from the audit is what made a HIGH advisory against ' + . 'squizlabs/php_codesniffer invisible to CI. Do not introduce it.', + ); + + // Non-vacuity, in both directions: the stripper must actually strip + // (the new job is commented) and must not have emptied the file. + $raw = (string) file_get_contents(self::WORKFLOW); + self::assertLessThan(strlen($raw), strlen($yaml), 'the comment stripper removed nothing at all'); + self::assertStringContainsString('composer-audit:', $yaml); + self::assertStringContainsString('php scripts/security-audit-check.php', $yaml); + } + + public function testTheAuditJobIsNotNeutered(): void + { + $job = $this->auditJob(); + + self::assertStringNotContainsString( + 'continue-on-error', + $job, + 'A security gate that cannot fail the build is the defect this replaces.', + ); + + self::assertDoesNotMatchRegularExpression( + '/^\s*if:/m', + $job, + 'A conditional audit job can be skipped, and a skipped check reads as a success.', + ); + } + + /** + * The flags are read from the declared constant rather than grepped out of + * the script body, so this cannot accidentally match a comment. + */ + public function testTheAuditIsInvokedWithoutTheDevExclusion(): void + { + $flags = $this->auditArguments(); + + self::assertContains('audit', $flags); + self::assertContains('--locked', $flags); + self::assertContains('--format=json', $flags); + self::assertNotContains( + '--no-dev', + $flags, + 'S246: the audit must cover require-dev packages.', + ); + } + + public function testFloorsMatchTheScript(): void + { + $source = (string) file_get_contents(self::SCRIPT); + + self::assertMatchesRegularExpression( + '/const MIN_AUDITED_PACKAGES = ' . self::MIN_PACKAGES . ';/', + $source, + 'Lowering the corpus floor is how this gate would be neutered.', + ); + + self::assertMatchesRegularExpression( + '/const MIN_AUDITED_DEV_PACKAGES = ' . self::MIN_DEV_PACKAGES . ';/', + $source, + 'Lowering the require-dev floor re-opens exactly the hole S246 closed.', + ); + } + + // ----------------------------------------------------------------------- + // The corpus is stated out loud. + // ----------------------------------------------------------------------- + + public function testItPrintsTheCorpusItExamined(): void + { + $runtime = self::MIN_PACKAGES; + $dev = self::MIN_DEV_PACKAGES; + + $result = $this->runGate($this->payload(['advisories' => []]), $this->lock($runtime, $dev)); + + self::assertSame(0, $result['exit'], $result['output']); + self::assertStringContainsString( + sprintf( + 'Audit corpus: %d locked package(s) — %d require, %d require-dev', + $runtime + $dev, + $runtime, + $dev, + ), + $result['output'], + ); + self::assertStringContainsString( + sprintf('No security advisories affecting the %d locked package(s)', $runtime + $dev), + $result['output'], + ); + } + + /** + * Against the repo's own lock, with no lock argument, the reported corpus + * must equal an independent count of that lock — and that count must clear + * both floors, so the floors are known to be satisfiable here. + * + * @throws JsonException + */ + public function testTheDefaultCorpusIsTheReposOwnLock(): void + { + /** @var array{packages: list>, packages-dev: list>} $lock */ + $lock = json_decode((string) file_get_contents(self::REAL_LOCK), true, 512, JSON_THROW_ON_ERROR); + $runtime = count($lock['packages']); + $dev = count($lock['packages-dev']); + + self::assertGreaterThanOrEqual( + self::MIN_PACKAGES, + $runtime + $dev, + 'the repo lock must clear the total floor the gate enforces', + ); + self::assertGreaterThanOrEqual( + self::MIN_DEV_PACKAGES, + $dev, + 'the repo lock must clear the require-dev floor the gate enforces', + ); + + $result = $this->runGate($this->payload(['advisories' => []])); + + self::assertSame(0, $result['exit'], $result['output']); + self::assertStringContainsString( + sprintf( + 'Audit corpus: %d locked package(s) — %d require, %d require-dev', + $runtime + $dev, + $runtime, + $dev, + ), + $result['output'], + ); + } + + public function testACorpusBelowTheTotalFloorFails(): void + { + $result = $this->runGate($this->payload(['advisories' => []]), $this->lock(0, self::MIN_DEV_PACKAGES)); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString( + sprintf( + '::error::Audit corpus is %d package(s), below the floor of %d', + self::MIN_DEV_PACKAGES, + self::MIN_PACKAGES, + ), + $result['output'], + ); + } + + /** + * The direct anti-regression: a lock whose dev half has been emptied — which + * is exactly what `--no-dev` produces — must not read as a clean audit. + */ + public function testACorpusWithNoDevelopmentPackagesFails(): void + { + $result = $this->runGate( + $this->payload(['advisories' => []]), + $this->lock(self::MIN_PACKAGES + self::MIN_DEV_PACKAGES, 0), + ); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString( + sprintf('::error::Only 0 require-dev package(s) in the corpus, below the floor of %d', self::MIN_DEV_PACKAGES), + $result['output'], + ); + self::assertStringContainsString('Do NOT restore --no-dev', $result['output']); + } + + public function testAMissingLockFails(): void + { + $result = $this->runGate($this->payload(['advisories' => []]), $this->workDir . '/absent.lock'); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString('::error::composer.lock not found', $result['output']); + } + + public function testAnUnparseableLockFails(): void + { + $path = $this->workDir . '/broken.lock'; + file_put_contents($path, '{ "packages": '); + + $result = $this->runGate($this->payload(['advisories' => []]), $path); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString('is not parseable JSON', $result['output']); + } + + public function testALockWithoutAPackagesKeyFails(): void + { + $path = $this->workDir . '/notalock.lock'; + file_put_contents($path, '{"hello":"world"}'); + + $result = $this->runGate($this->payload(['advisories' => []]), $path); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString('has no "packages" key', $result['output']); + } + + // ----------------------------------------------------------------------- + // The blocking verdict — including the dev half, which is the point. + // ----------------------------------------------------------------------- + + /** + * The exact 2026-08-06 finding, replayed: a HIGH advisory against a package + * that lives in `require-dev`. With no audit gate, or with a `--no-dev` one, + * this was invisible. + */ + public function testAnAdvisoryAgainstADevelopmentDependencyBlocks(): void + { + $lock = $this->lock( + self::MIN_PACKAGES, + self::MIN_DEV_PACKAGES, + ['squizlabs/php_codesniffer' => ['scope' => 'dev', 'version' => '4.0.1']], + ); + + $result = $this->runGate( + $this->payload([ + 'advisories' => [ + 'squizlabs/php_codesniffer' => [[ + 'advisoryId' => 'PKSA-vvvv-wwww-xxxx', + 'packageName' => 'squizlabs/php_codesniffer', + 'affectedVersions' => '<3.13.6|>=4.0.0,<4.0.2', + 'title' => 'OS command injection in the diff/report writer', + 'cve' => 'CVE-2026-67434', + 'link' => 'https://github.com/advisories/GHSA-hmqg-cxww-wqhq', + 'severity' => 'high', + ]], + ], + ]), + $lock, + ); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString( + '::error::Security audit FAILED — 1 advisory/ies affecting 1 package(s) (0 require, 1 require-dev).', + $result['output'], + ); + self::assertStringContainsString( + 'squizlabs/php_codesniffer [require-dev] locked at 4.0.1 (affected: <3.13.6|>=4.0.0,<4.0.2)', + $result['output'], + ); + self::assertStringContainsString('[HIGH] CVE-2026-67434 — OS command injection', $result['output']); + self::assertStringContainsString('https://github.com/advisories/GHSA-hmqg-cxww-wqhq', $result['output']); + } + + public function testAnAdvisoryAgainstARuntimeDependencyBlocksAndIsLabelledAsSuch(): void + { + $lock = $this->lock( + self::MIN_PACKAGES, + self::MIN_DEV_PACKAGES, + ['vendor/runtime-thing' => ['scope' => 'runtime', 'version' => '1.0.0']], + ); + + $result = $this->runGate( + $this->payload([ + 'advisories' => [ + 'vendor/runtime-thing' => [[ + 'advisoryId' => 'PKSA-aaaa-bbbb-cccc', + 'affectedVersions' => '<1.0.1', + 'title' => 'Remote code execution', + 'cve' => 'CVE-2026-00001', + 'severity' => 'critical', + ]], + ], + ]), + $lock, + ); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString('(1 require, 0 require-dev)', $result['output']); + self::assertStringContainsString('vendor/runtime-thing [require] locked at 1.0.0', $result['output']); + self::assertStringContainsString('[CRITICAL] CVE-2026-00001', $result['output']); + } + + /** + * A succeeding control beside the failure: the same corpus, the same script, + * an empty advisory set. Without this the red above could be produced by + * anything at all. + */ + public function testTheSameCorpusPassesWhenThereAreNoAdvisories(): void + { + $lock = $this->lock( + self::MIN_PACKAGES, + self::MIN_DEV_PACKAGES, + ['squizlabs/php_codesniffer' => ['scope' => 'dev', 'version' => '4.0.4']], + ); + + $result = $this->runGate($this->payload(['advisories' => [], 'abandoned' => []]), $lock); + + self::assertSame(0, $result['exit'], $result['output']); + self::assertStringContainsString('Security audit passed.', $result['output']); + self::assertStringNotContainsString('::error::', $result['output']); + } + + // ----------------------------------------------------------------------- + // Advisory-only findings are loud but do not block. + // ----------------------------------------------------------------------- + + public function testAbandonedPackagesWarnLoudlyButDoNotBlock(): void + { + $lock = $this->lock( + self::MIN_PACKAGES, + self::MIN_DEV_PACKAGES, + ['fgrosse/phpasn1' => ['scope' => 'runtime', 'version' => '2.5.0']], + ); + + $result = $this->runGate( + $this->payload([ + 'advisories' => [], + 'abandoned' => ['fgrosse/phpasn1' => '', 'web-auth/metadata-service' => 'web-auth/webauthn-lib'], + ]), + $lock, + ); + + self::assertSame(0, $result['exit'], $result['output']); + self::assertStringContainsString('::warning::2 abandoned package(s). ADVISORY ONLY', $result['output']); + self::assertStringContainsString('fgrosse/phpasn1 [require] — no replacement suggested', $result['output']); + self::assertStringContainsString( + 'web-auth/metadata-service [not in lock] — replaced by web-auth/webauthn-lib', + $result['output'], + ); + self::assertStringContainsString('Security audit passed.', $result['output']); + } + + public function testAnAdvisoryStillBlocksWhenAbandonedPackagesArePresent(): void + { + $lock = $this->lock( + self::MIN_PACKAGES, + self::MIN_DEV_PACKAGES, + ['vendor/broken' => ['scope' => 'dev', 'version' => '2.0.0']], + ); + + $result = $this->runGate( + $this->payload([ + 'advisories' => [ + 'vendor/broken' => [[ + 'advisoryId' => 'PKSA-dddd-eeee-ffff', + 'title' => 'Path traversal', + 'cve' => 'CVE-2026-00002', + 'severity' => 'medium', + ]], + ], + 'abandoned' => ['fgrosse/phpasn1' => ''], + ]), + $lock, + ); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString('::warning::1 abandoned package(s)', $result['output']); + self::assertStringContainsString('::error::Security audit FAILED', $result['output']); + } + + public function testConfigIgnoredAdvisoriesAreReportedAsAcknowledgedNotHidden(): void + { + $lock = $this->lock( + self::MIN_PACKAGES, + self::MIN_DEV_PACKAGES, + ['vendor/unfixable' => ['scope' => 'runtime', 'version' => '3.1.0']], + ); + + $result = $this->runGate( + $this->payload([ + 'advisories' => [], + 'ignored-advisories' => [ + 'vendor/unfixable' => [[ + 'advisoryId' => 'PKSA-gggg-hhhh-iiii', + 'title' => 'Denial of service', + 'cve' => 'CVE-2026-00003', + 'severity' => 'low', + ]], + ], + ]), + $lock, + ); + + self::assertSame(0, $result['exit'], $result['output']); + self::assertStringContainsString( + '::notice::1 security advisory/ies affecting 1 package(s) are IGNORED', + $result['output'], + ); + self::assertStringContainsString('vendor/unfixable [require] locked at 3.1.0', $result['output']); + } + + // ----------------------------------------------------------------------- + // Cannot-measure must fail, never skip. + // ----------------------------------------------------------------------- + + public function testAnUnreachableAdvisoryRepositoryFails(): void + { + $result = $this->runGate( + $this->payload(['advisories' => [], 'unreachable-repositories' => ['https://repo.packagist.org']]), + $this->lock(self::MIN_PACKAGES, self::MIN_DEV_PACKAGES), + ); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString( + '::error::1 advisory repository/ies were unreachable — the audit measured nothing.', + $result['output'], + ); + } + + public function testAMissingPayloadFails(): void + { + $result = $this->runGate( + $this->workDir . '/absent.json', + $this->lock(self::MIN_PACKAGES, self::MIN_DEV_PACKAGES), + ); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString('does not exist', $result['output']); + } + + public function testAnEmptyPayloadFails(): void + { + $path = $this->workDir . '/empty.json'; + file_put_contents($path, " \n"); + + $result = $this->runGate($path, $this->lock(self::MIN_PACKAGES, self::MIN_DEV_PACKAGES)); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString('is empty', $result['output']); + } + + public function testAnUnparseablePayloadFails(): void + { + $path = $this->workDir . '/bad.json'; + file_put_contents($path, 'not json at all'); + + $result = $this->runGate($path, $this->lock(self::MIN_PACKAGES, self::MIN_DEV_PACKAGES)); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString('is not parseable JSON', $result['output']); + } + + public function testAPayloadWithoutAnAdvisoriesKeyFails(): void + { + $result = $this->runGate( + $this->payload(['something-else' => []]), + $this->lock(self::MIN_PACKAGES, self::MIN_DEV_PACKAGES), + ); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString('has no "advisories" key', $result['output']); + } + + public function testAMissingComposerBinaryFailsInsteadOfSkipping(): void + { + $result = $this->runGate( + null, + $this->lock(self::MIN_PACKAGES, self::MIN_DEV_PACKAGES), + ['COMPOSER_BIN' => '/nonexistent/composer'], + ); + + self::assertSame(1, $result['exit'], $result['output']); + self::assertStringContainsString( + '::error::Cannot run "/nonexistent/composer --version"', + $result['output'], + ); + } + + // ----------------------------------------------------------------------- + // Helpers. + // ----------------------------------------------------------------------- + + /** + * Run the gate as a subprocess and capture its merged output and exit code. + * + * @param array $env + * + * @return array{exit: int, output: string} + */ + private function runGate(?string $payloadPath, ?string $lockPath = null, array $env = []): array + { + $command = ['php', self::SCRIPT]; + + if ($payloadPath !== null) { + $command[] = $payloadPath; + + if ($lockPath !== null) { + $command[] = $lockPath; + } + } elseif ($lockPath !== null) { + $command[] = ''; + $command[] = $lockPath; + } + + $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $pipes = []; + $process = proc_open( + $command, + $descriptors, + $pipes, + null, + $env === [] ? null : $env + ['PATH' => (string) getenv('PATH')], + ); + + self::assertNotFalse($process, 'could not start the gate script'); + + fclose($pipes[0]); + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return ['exit' => proc_close($process), 'output' => $stdout . $stderr]; + } + + /** + * Write a `composer audit --format=json` payload and return its path. + * + * @param array $payload + */ + private function payload(array $payload): string + { + $path = $this->workDir . '/audit-' . bin2hex(random_bytes(4)) . '.json'; + file_put_contents($path, (string) json_encode($payload, JSON_PRETTY_PRINT)); + + return $path; + } + + /** + * Write a synthetic `composer.lock` with a known package count. + * + * Named packages are placed in the requested section so the scope label can + * be asserted; the filler packages make up the remainder of the count. + * + * @param array $named + */ + private function lock(int $runtime, int $dev, array $named = []): string + { + /** @var array{packages: list, packages-dev: list} $sections */ + $sections = ['packages' => [], 'packages-dev' => []]; + + foreach ($named as $name => $spec) { + $key = $spec['scope'] === 'dev' ? 'packages-dev' : 'packages'; + $sections[$key][] = ['name' => $name, 'version' => $spec['version']]; + } + + while (count($sections['packages']) < $runtime) { + $sections['packages'][] = ['name' => 'filler/runtime-' . count($sections['packages']), 'version' => '1.0.0']; + } + + while (count($sections['packages-dev']) < $dev) { + $sections['packages-dev'][] = ['name' => 'filler/dev-' . count($sections['packages-dev']), 'version' => '1.0.0']; + } + + $path = $this->workDir . '/composer-' . bin2hex(random_bytes(4)) . '.lock'; + file_put_contents($path, (string) json_encode($sections, JSON_PRETTY_PRINT)); + + return $path; + } + + /** + * The workflow with every YAML comment removed. + * + * The audit job documents what `--no-dev` used to do, so a raw substring + * search over this file would match the explanation rather than the + * configuration. + */ + private function workflowWithoutComments(): string + { + $lines = explode("\n", (string) file_get_contents(self::WORKFLOW)); + $kept = []; + + foreach ($lines as $line) { + $stripped = preg_replace('/(?:^|\s)#.*$/', '', $line); + $kept[] = is_string($stripped) ? $stripped : $line; + } + + return implode("\n", $kept); + } + + /** + * Everything in the workflow from the audit job onwards, comments stripped. + * + * The audit job is deliberately the LAST job in the file, so this slice is + * exactly that job — a neutering flag on some other job cannot satisfy or + * break the assertions above. + */ + private function auditJob(): string + { + $yaml = $this->workflowWithoutComments(); + $offset = strpos($yaml, 'composer-audit:'); + + self::assertNotFalse($offset, 'the workflow must declare a composer-audit job'); + + $job = substr($yaml, $offset); + + self::assertStringContainsString( + 'php scripts/security-audit-check.php', + $job, + 'the audit job must be the last job in the workflow, so this slice is only that job', + ); + + return $job; + } + + /** + * The audit flags the script declares, read from the constant itself. + * + * @return list + */ + private function auditArguments(): array + { + $source = (string) file_get_contents(self::SCRIPT); + $matches = []; + + self::assertSame( + 1, + preg_match('/const AUDIT_ARGUMENTS = \[(.*?)\];/s', $source, $matches), + 'scripts/security-audit-check.php must declare AUDIT_ARGUMENTS — this test reads the ' + . 'declared flags rather than pattern-matching prose, and an absent constant is a ' + . 'silent pass otherwise.', + ); + + $declaration = $matches[1] ?? ''; + + self::assertNotSame('', $declaration, 'AUDIT_ARGUMENTS matched but captured nothing.'); + + $flags = []; + $found = []; + + if (preg_match_all("/'([^']+)'/", $declaration, $found) > 0) { + $flags = $found[1]; + } + + self::assertNotSame([], $flags, 'AUDIT_ARGUMENTS parsed to an empty list — the assertion below would be vacuous.'); + + return $flags; + } +}