| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /proc/2798582/cwd/scripts/ci/ |
Upload File : |
<?php
declare(strict_types=1);
/**
* Plan 270 §Phase 7 — Composer audit CI gate.
*
* Reads `composer audit --format=json` output on stdin, filters out
* advisories acknowledged in `scripts/ci/composer-audit-baseline.json`,
* and fails the build only on NEW high or critical advisories.
*
* Why a baseline file: the repo inherits a backlog of high/critical
* advisories on transitive dependencies. Blocking every merge until
* the entire backlog clears is hostile; blocking on NEW ones the day
* they land is the right tradeoff. Each baseline entry must have an
* owner + a target date to keep the list from rotting.
*
* Low + medium advisories surface as a CI annotation (non-blocking)
* so the team sees them without the merge queue jamming.
*
* Exit codes:
* 0 -> no new high/critical advisories outside the baseline
* 1 -> at least one new high/critical advisory found
* 2 -> usage / parsing error
*
* Usage (in CI):
* composer audit --format=json --no-dev | php scripts/ci/composer-audit-gate.php
*/
$baselinePath = __DIR__.'/composer-audit-baseline.json';
$baseline = [];
if (is_file($baselinePath)) {
$rawBaseline = file_get_contents($baselinePath);
if ($rawBaseline === false) {
fwrite(STDERR, "composer-audit-gate: could not read baseline file at {$baselinePath}\n");
exit(2);
}
$decoded = json_decode($rawBaseline, true);
if (! is_array($decoded) || ! isset($decoded['acknowledged']) || ! is_array($decoded['acknowledged'])) {
fwrite(STDERR, "composer-audit-gate: baseline is malformed (expecting {\"acknowledged\": [...]})\n");
exit(2);
}
foreach ($decoded['acknowledged'] as $entry) {
if (is_array($entry) && isset($entry['advisoryId']) && is_string($entry['advisoryId'])) {
$baseline[$entry['advisoryId']] = $entry;
}
}
}
$stdin = stream_get_contents(STDIN);
if ($stdin === false || trim($stdin) === '') {
fwrite(STDERR, "composer-audit-gate: no audit JSON on stdin\n");
exit(2);
}
$payload = json_decode($stdin, true);
if (! is_array($payload)) {
fwrite(STDERR, "composer-audit-gate: stdin was not valid JSON\n");
exit(2);
}
$advisories = $payload['advisories'] ?? [];
if (! is_array($advisories)) {
$advisories = [];
}
$newBlocking = [];
$baselineHits = [];
$lowMedium = [];
foreach ($advisories as $packageAdvisories) {
if (! is_array($packageAdvisories)) {
continue;
}
foreach ($packageAdvisories as $advisory) {
if (! is_array($advisory)) {
continue;
}
$severity = strtolower((string) ($advisory['severity'] ?? ''));
$id = (string) ($advisory['advisoryId'] ?? '');
if ($id === '') {
// Advisories without an id can't be baselined deterministically;
// treat as blocking when high/critical, surface otherwise.
if (in_array($severity, ['high', 'critical'], true)) {
$newBlocking[] = $advisory;
} else {
$lowMedium[] = $advisory;
}
continue;
}
if (in_array($severity, ['high', 'critical'], true)) {
if (isset($baseline[$id])) {
$baselineHits[] = ['advisory' => $advisory, 'baseline' => $baseline[$id]];
} else {
$newBlocking[] = $advisory;
}
} elseif (in_array($severity, ['low', 'medium'], true)) {
$lowMedium[] = $advisory;
}
}
}
// Emit a baseline-rot warning: any baseline entry whose target_date is in
// the past should be cleared, not extended silently.
$now = new \DateTimeImmutable;
$rotted = [];
foreach ($baseline as $entry) {
if (! isset($entry['target_date']) || ! is_string($entry['target_date'])) {
continue;
}
try {
$target = new \DateTimeImmutable($entry['target_date']);
} catch (\Throwable) {
continue;
}
if ($target < $now) {
$rotted[] = $entry;
}
}
if ($lowMedium !== []) {
fwrite(STDOUT, "::warning::composer audit found ".count($lowMedium)." low/medium advisory(ies) — non-blocking. Run `composer audit` locally for details.\n");
}
if ($rotted !== []) {
fwrite(STDOUT, "::warning::composer-audit baseline has ".count($rotted)." entry(ies) past their target_date. Review or clear them.\n");
foreach ($rotted as $entry) {
fwrite(STDOUT, " - {$entry['advisoryId']} ({$entry['packageName']}) target {$entry['target_date']} owner {$entry['owner']}\n");
}
}
if ($baselineHits !== []) {
fwrite(STDOUT, "composer-audit-gate: ".count($baselineHits)." high/critical advisory(ies) matched the baseline and were allowed:\n");
foreach ($baselineHits as $hit) {
$a = $hit['advisory'];
$b = $hit['baseline'];
fwrite(STDOUT, " - {$a['advisoryId']} {$a['packageName']} ({$a['severity']}) owner={$b['owner']} target={$b['target_date']}\n");
}
}
if ($newBlocking === []) {
fwrite(STDOUT, "composer-audit-gate: OK (no new high/critical advisories)\n");
exit(0);
}
fwrite(STDERR, "\ncomposer-audit-gate: FAILED — ".count($newBlocking)." new high/critical advisory(ies) outside the baseline:\n\n");
foreach ($newBlocking as $advisory) {
$id = $advisory['advisoryId'] ?? '<no id>';
$pkg = $advisory['packageName'] ?? '<unknown>';
$sev = $advisory['severity'] ?? '<unknown>';
$title = $advisory['title'] ?? '';
$cve = $advisory['cve'] ?? null;
fwrite(STDERR, " - [{$sev}] {$id} {$pkg} {$cve}\n {$title}\n");
fwrite(STDOUT, "::error title=composer audit::[{$sev}] {$pkg}: {$title} ({$id})\n");
}
fwrite(STDERR, "\nResolution options:\n");
fwrite(STDERR, " 1. Upgrade the affected package (`composer update <pkg>`).\n");
fwrite(STDERR, " 2. If upgrade isn't feasible, add an entry to scripts/ci/composer-audit-baseline.json\n");
fwrite(STDERR, " with `owner`, `target_date`, and `notes` explaining the deferral.\n\n");
exit(1);