Skip to content

Commit 3053eaf

Browse files
chrisleekrclaude
andcommitted
fix(ci): never trust audit output from a terminated run, narrow the skip
Two fail-opens in the audit gate. A killed `bun audit` can flush parseable JSON before it dies. The empty- stdout check ran first, so that partial output reached JSON.parse and was reported as a complete verdict. Verified against the pre-fix script: a process that writes {"advisories":{}} then hangs yields "Summary: blocking=0 ... total=0" and exit 0. Termination is now decided before stdout is looked at. The outage skip also applied to any failure to run, so a bun crash or an unreadable lockfile would silently skip the audit too. It is now gated on an identified unreachable-service condition; every other failure to run still exits non-zero. Detect termination through signalCode / exitedDueToTimeout rather than `exitCode === null`. The runtime does return null there on 1.3.12 and 1.3.14, but the declared type is `number`, so the comparison is not expressible in typed code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KUPpJPtxAaHWrBsjytRGyM
1 parent ec39005 commit 3053eaf

1 file changed

Lines changed: 51 additions & 25 deletions

File tree

scripts/audit-ci.ts

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -69,45 +69,71 @@ function runAudit(): Attempt {
6969
stdout: new TextDecoder().decode(proc.stdout).trim(),
7070
stderr: new TextDecoder().decode(proc.stderr).trim(),
7171
exitCode: proc.exitCode,
72-
// A timeout kill arrives as SIGTERM, which leaves exitCode null. Classify
73-
// that as a failure to run, not as a clean "no advisories" result, so the
74-
// outcome is reported as a skipped audit rather than as a pass.
75-
signalled: proc.exitCode === null,
72+
// `exitCode` is declared `number` but is null at runtime on a signal kill
73+
// (verified on bun 1.3.12 and 1.3.14), so it cannot carry this check in
74+
// typed code. These two optional fields are the documented way to detect
75+
// termination: https://bun.com/reference/bun/SyncSubprocess
76+
signalled: proc.signalCode != null || proc.exitedDueToTimeout === true,
7677
};
7778
}
7879

80+
// `bun audit` exits 1 when it FINDS advisories, so a non-zero exit alongside a
81+
// report is the normal path. Only an empty report marks a run that never
82+
// happened.
83+
function failedToRun(a: Attempt): boolean {
84+
return a.signalled || (a.exitCode !== null && a.exitCode !== 0);
85+
}
86+
87+
// A killed process can still have flushed parseable JSON, which would be a
88+
// partial report. Treat any termination as "no report" so a truncated one is
89+
// never mistaken for a verdict.
90+
function producedNoReport(a: Attempt): boolean {
91+
return a.signalled || (!a.stdout && failedToRun(a));
92+
}
93+
94+
// Only a genuine failure to reach the advisory service earns the skip below.
95+
// Every other failure to run (bun crash, unreadable lockfile, bad credentials)
96+
// says something about this repo and still hard-fails.
97+
const SERVICE_UNREACHABLE =
98+
/audit request failed|ConnectionClosed|ConnectionRefused|ConnectionTimedOut|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed/i;
99+
100+
function isServiceUnreachable(a: Attempt): boolean {
101+
// A killed attempt is one that got 60s to answer and never did, which is
102+
// unreachable from this side whatever the cause.
103+
return a.signalled || SERVICE_UNREACHABLE.test(a.stderr);
104+
}
105+
106+
// Reached only when every attempt failed to produce a report. An unreachable
107+
// advisory service yields no verdict in either direction, and blocking every
108+
// merge for the length of an upstream outage buys no security, so warn loudly
109+
// and pass. Anything else exits non-zero.
110+
function skipOrFail(a: Attempt): never {
111+
const how = a.signalled ? "was killed after not responding" : `exited with code ${a.exitCode}`;
112+
if (isServiceUnreachable(a)) {
113+
console.warn(
114+
`::warning::bun audit ${how} and produced no usable JSON after ${MAX_ATTEMPTS} attempts. Dependency audit SKIPPED for this run.`,
115+
);
116+
if (a.stderr) console.error(a.stderr);
117+
process.exit(0);
118+
}
119+
console.error(`::error::bun audit ${how} without reaching the advisory service.`);
120+
if (a.stderr) console.error(a.stderr);
121+
process.exit(a.exitCode ?? 1);
122+
}
123+
79124
let attempt = runAudit();
80-
for (let i = 2; i <= MAX_ATTEMPTS && !attempt.stdout && failedToRun(attempt); i++) {
125+
for (let i = 2; i <= MAX_ATTEMPTS && producedNoReport(attempt); i++) {
81126
console.warn(`::warning::bun audit attempt ${i - 1}/${MAX_ATTEMPTS} produced no JSON, retrying.`);
82127
if (attempt.stderr) console.error(attempt.stderr);
83128
Bun.sleepSync(BACKOFF_MS);
84129
attempt = runAudit();
85130
}
86131

87-
function failedToRun(a: Attempt): boolean {
88-
return a.signalled || (a.exitCode !== null && a.exitCode !== 0);
89-
}
132+
if (producedNoReport(attempt)) skipOrFail(attempt);
90133

91134
const { stdout, stderr } = attempt;
92135

93136
if (!stdout) {
94-
// Empty stdout + a signal or non-zero exit means bun audit failed to RUN
95-
// (registry outage, network error), not that it audited cleanly. The retries
96-
// above already absorbed a transient blip, so reaching here means the
97-
// advisory service is down and the gate has no signal either way. Blocking
98-
// every merge for the length of an upstream outage buys no security, so warn
99-
// loudly and pass. Real advisories and unparseable output below still
100-
// hard-fail, and trivy-scan.yml scans the published images daily.
101-
if (failedToRun(attempt)) {
102-
const how = attempt.signalled
103-
? "was killed by a signal"
104-
: `exited with code ${attempt.exitCode}`;
105-
console.warn(
106-
`::warning::bun audit ${how} and produced no JSON after ${MAX_ATTEMPTS} attempts. Dependency audit SKIPPED for this run.`,
107-
);
108-
if (stderr) console.error(stderr);
109-
process.exit(0);
110-
}
111137
console.log("bun audit produced no JSON output (no advisories).");
112138
if (stderr) console.error(stderr);
113139
process.exit(0);

0 commit comments

Comments
 (0)