@cosmicdrift/kumiko-guards 0.362.0 → 0.364.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Parse and evaluate `bun audit --json` reports with exception support.
3
+ */
4
+ import { isExceptionValid, matchesPackageName } from "./dependency-exceptions";
5
+
6
+ export type Severity = "critical" | "high" | "moderate" | "low" | "info";
7
+
8
+ export type RawAuditEntry = {
9
+ readonly id?: number;
10
+ readonly url?: string;
11
+ readonly title?: string;
12
+ readonly severity?: string;
13
+ readonly vulnerable_versions?: string;
14
+ };
15
+
16
+ export type RawAuditReport = Record<string, readonly RawAuditEntry[]>;
17
+
18
+ export type AuditAdvisory = {
19
+ readonly package: string;
20
+ readonly severity: Severity;
21
+ readonly advisoryId: string;
22
+ readonly url: string;
23
+ readonly title: string;
24
+ };
25
+
26
+ export type SecurityException = {
27
+ readonly advisory: string;
28
+ readonly package: string;
29
+ readonly reason: string;
30
+ readonly reviewedBy: string;
31
+ readonly expires: string;
32
+ };
33
+
34
+ export type SecurityEvaluation = {
35
+ readonly blocking: AuditAdvisory[];
36
+ readonly warnings: AuditAdvisory[];
37
+ readonly suppressed: AuditAdvisory[];
38
+ readonly counts: Record<Severity, number>;
39
+ };
40
+
41
+ const FAIL_SEVERITIES = new Set<Severity>(["critical", "high"]);
42
+
43
+ export function parseAuditReport(raw: RawAuditReport): AuditAdvisory[] {
44
+ const advisories: AuditAdvisory[] = [];
45
+ for (const [pkg, entries] of Object.entries(raw)) {
46
+ for (const entry of entries) {
47
+ const severity = normalizeSeverity(entry.severity);
48
+ if (!severity) continue;
49
+ advisories.push({
50
+ package: pkg,
51
+ severity,
52
+ advisoryId: resolveAdvisoryId(entry),
53
+ url: entry.url ?? "",
54
+ title: entry.title ?? "",
55
+ });
56
+ }
57
+ }
58
+ return advisories;
59
+ }
60
+
61
+ function normalizeSeverity(raw: string | undefined): Severity | null {
62
+ if (!raw) return null;
63
+ const lower = raw.toLowerCase();
64
+ if (
65
+ lower === "critical" ||
66
+ lower === "high" ||
67
+ lower === "moderate" ||
68
+ lower === "low" ||
69
+ lower === "info"
70
+ ) {
71
+ return lower;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function resolveAdvisoryId(entry: RawAuditEntry): string {
77
+ if (entry.url) {
78
+ const ghsa = entry.url.match(/GHSA-[a-z0-9-]+/i);
79
+ if (ghsa) return ghsa[0].toUpperCase();
80
+ }
81
+ if (entry.id !== undefined) return String(entry.id);
82
+ return "unknown";
83
+ }
84
+
85
+ function matchesAdvisory(exceptionAdvisory: string, advisoryId: string, url: string): boolean {
86
+ if (exceptionAdvisory.trim().toUpperCase() === advisoryId.toUpperCase()) return true;
87
+ return url.includes(exceptionAdvisory);
88
+ }
89
+
90
+ function findSecurityException(
91
+ advisory: AuditAdvisory,
92
+ exceptions: readonly SecurityException[],
93
+ ): SecurityException | undefined {
94
+ return exceptions.find(
95
+ (ex) =>
96
+ matchesAdvisory(ex.advisory, advisory.advisoryId, advisory.url) &&
97
+ (ex.package === "*" || matchesPackageName(ex.package, advisory.package)),
98
+ );
99
+ }
100
+
101
+ export function evaluateSecurity(
102
+ advisories: readonly AuditAdvisory[],
103
+ exceptions: readonly SecurityException[],
104
+ nowMs: number = Date.now(),
105
+ ): SecurityEvaluation {
106
+ const counts: Record<Severity, number> = { critical: 0, high: 0, moderate: 0, low: 0, info: 0 };
107
+ const blocking: AuditAdvisory[] = [];
108
+ const warnings: AuditAdvisory[] = [];
109
+ const suppressed: AuditAdvisory[] = [];
110
+
111
+ for (const advisory of advisories) {
112
+ counts[advisory.severity] += 1;
113
+ const exception = findSecurityException(advisory, exceptions);
114
+ // isExceptionValid() treats a missing expiry as "forever valid", which is wrong for security: such an exception would suppress a critical permanently.
115
+ const covered =
116
+ exception !== undefined &&
117
+ Boolean(exception.expires) &&
118
+ isExceptionValid(exception.expires, nowMs).valid;
119
+
120
+ if (covered) {
121
+ suppressed.push(advisory);
122
+ } else if (FAIL_SEVERITIES.has(advisory.severity)) {
123
+ blocking.push(advisory);
124
+ } else if (advisory.severity === "moderate") {
125
+ warnings.push(advisory);
126
+ }
127
+ }
128
+
129
+ return { blocking, warnings, suppressed, counts };
130
+ }
131
+
132
+ /** True when `bytes` starts with the gzip magic header (1f 8b). */
133
+ export function isGzipPayload(bytes: Uint8Array): boolean {
134
+ return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
135
+ }
136
+
137
+ // Built from char code 27 (ESC) so the control character never appears in a regex literal.
138
+ const CSI_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[a-zA-Z]`, "g");
139
+
140
+ /** Strip CSI escape sequences — some bun builds color `--json` under CI, and
141
+ * some CI runners also emit cursor/line-clear CSI codes (\x1b[2K, \x1b[?25l)
142
+ * on the same pipe; either survives a color-only (SGR) regex and breaks
143
+ * JSON.parse the same way raw color codes did. */
144
+ export function stripAnsi(text: string): string {
145
+ return text.replace(CSI_SEQUENCE, "");
146
+ }
147
+
148
+ /** Decode bun audit stdout/stderr — some CI runners get gzip bytes on the pipe. */
149
+ export function decodeBunAuditPayload(bytes: Uint8Array): string {
150
+ if (bytes.length === 0) return "";
151
+ // Always copy first — Bun.gunzipSync typings reject SharedArrayBuffer views,
152
+ // and a fresh Uint8Array also makes the gzip magic check reliable on CI.
153
+ const copy = Uint8Array.from(bytes);
154
+ const body = isGzipPayload(copy) ? Bun.gunzipSync(copy) : copy;
155
+ return stripLeadingNonJson(stripAnsi(new TextDecoder().decode(body)).trim());
156
+ }
157
+
158
+ /** Bun may print `bun audit vX` banner on the same stream as `--json` body.
159
+ * No `{` at all means there IS no body — the advisory fetch failed and only
160
+ * the banner arrived. Returning the raw text there sent the banner into
161
+ * JSON.parse and surfaced a network outage as `Unexpected identifier "bun"`
162
+ * . Empty string lets the caller skip the stream instead. */
163
+ export function stripLeadingNonJson(text: string): string {
164
+ const start = text.indexOf("{");
165
+ if (start < 0) return "";
166
+ return text.slice(start);
167
+ }
168
+
169
+ /** Verbatim head of a stream, for the "no JSON anywhere" diagnostic. */
170
+ function streamHead(bytes: Uint8Array): string {
171
+ if (bytes.length === 0) return "(empty)";
172
+ const copy = Uint8Array.from(bytes);
173
+ const body = isGzipPayload(copy) ? Bun.gunzipSync(copy) : copy;
174
+ return stripAnsi(new TextDecoder().decode(body)).trim().slice(0, 200) || "(empty)";
175
+ }
176
+
177
+ export class BunAuditUnavailableError extends Error {
178
+ constructor(
179
+ stdoutBytes: Uint8Array,
180
+ stderrBytes: Uint8Array,
181
+ exitCode?: number,
182
+ // Set only when *we* killed the process for running past its timeout —
183
+ // that is a self-inflicted abort, not evidence of a network/registry outage.
184
+ killedAfterTimeoutMs?: number,
185
+ ) {
186
+ const reason =
187
+ killedAfterTimeoutMs === undefined
188
+ ? "the advisory fetch likely failed (network/registry)"
189
+ : `bun audit was killed after exceeding its ${killedAfterTimeoutMs}ms timeout`;
190
+ super(
191
+ `bun audit produced no JSON body on either stream — ${reason}. This is not a vulnerability finding.` +
192
+ (exitCode === undefined ? "" : ` (exit ${exitCode})`) +
193
+ `\n stdout: ${streamHead(stdoutBytes)}\n stderr: ${streamHead(stderrBytes)}`,
194
+ );
195
+ this.name = "BunAuditUnavailableError";
196
+ }
197
+ }
198
+
199
+ function isAuditEntry(value: unknown): value is RawAuditEntry {
200
+ return typeof value === "object" && value !== null && !Array.isArray(value);
201
+ }
202
+
203
+ function isAuditReport(value: unknown): value is RawAuditReport {
204
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
205
+ return Object.values(value).every(
206
+ (entries) => Array.isArray(entries) && entries.every(isAuditEntry),
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Pick the first stdout/stderr decode that JSON.parse accepts as an object.
212
+ * Bun may put gzip JSON on one stream and a text warning on the other —
213
+ * preferring `stdout || stderr` then failed when stdout was non-empty garbage.
214
+ */
215
+ export function parseBunAuditStreams(
216
+ stdoutBytes: Uint8Array,
217
+ stderrBytes: Uint8Array,
218
+ exitCode?: number,
219
+ ): RawAuditReport {
220
+ // Decode inside the loop — corrupt gzip on one stream must not kill the other
221
+ // before we try JSON.parse.
222
+ const errors: string[] = [];
223
+ let tried = 0;
224
+ for (const raw of [stdoutBytes, stderrBytes]) {
225
+ try {
226
+ const payload = decodeBunAuditPayload(raw);
227
+ if (!payload) continue;
228
+ const parsed: unknown = JSON.parse(payload);
229
+ tried += 1;
230
+ if (!isAuditReport(parsed)) {
231
+ errors.push("JSON value is not a plain object of advisory arrays");
232
+ continue;
233
+ }
234
+ return parsed;
235
+ } catch (err) {
236
+ tried += 1;
237
+ errors.push(err instanceof Error ? err.message : String(err));
238
+ }
239
+ }
240
+ // Nothing even looked like JSON — tool/network problem, not malformed data.
241
+ // Distinct type so runBunAudit can retry it and the message can say so.
242
+ if (tried === 0) throw new BunAuditUnavailableError(stdoutBytes, stderrBytes, exitCode);
243
+ throw new Error(
244
+ `bun audit returned non-JSON (tried ${tried} stream(s)):\n${errors.join("\n").slice(0, 400)}`,
245
+ );
246
+ }
247
+
248
+ export interface AuditProcess {
249
+ readonly stdout: ReadableStream<Uint8Array>;
250
+ readonly stderr: ReadableStream<Uint8Array>;
251
+ readonly exited: Promise<number>;
252
+ kill(signal?: NodeJS.Signals): void;
253
+ }
254
+
255
+ function spawnBunAudit(root: string): AuditProcess {
256
+ return Bun.spawn(["bun", "audit", "--json"], {
257
+ cwd: root,
258
+ stdout: "pipe",
259
+ stderr: "pipe",
260
+ // NO_COLOR — belt-and-suspenders with stripAnsi for colored --json.
261
+ env: { ...process.env, CI: "1", NO_COLOR: "1", FORCE_COLOR: "0" },
262
+ });
263
+ }
264
+
265
+ const AUDIT_SIGKILL_GRACE_MS = 5_000;
266
+
267
+ export async function runBunAuditOnce(
268
+ root: string,
269
+ timeoutMs: number,
270
+ spawnAudit: (root: string) => AuditProcess = spawnBunAudit,
271
+ ): Promise<RawAuditReport> {
272
+ const proc = spawnAudit(root);
273
+
274
+ let timedOut = false;
275
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
276
+ const timer = setTimeout(() => {
277
+ timedOut = true;
278
+ proc.kill();
279
+ // A child ignoring SIGTERM would keep `exited` pending forever.
280
+ killTimer = setTimeout(() => proc.kill("SIGKILL"), AUDIT_SIGKILL_GRACE_MS);
281
+ }, timeoutMs);
282
+ let stdoutBytes: Uint8Array;
283
+ let stderrBytes: Uint8Array;
284
+ let exitCode: number;
285
+ try {
286
+ [stdoutBytes, stderrBytes, exitCode] = await Promise.all([
287
+ new Response(proc.stdout).bytes(),
288
+ new Response(proc.stderr).bytes(),
289
+ proc.exited,
290
+ ]);
291
+ } finally {
292
+ clearTimeout(timer);
293
+ clearTimeout(killTimer);
294
+ }
295
+
296
+ if (stdoutBytes.length === 0 && stderrBytes.length === 0) {
297
+ throw new BunAuditUnavailableError(
298
+ stdoutBytes,
299
+ stderrBytes,
300
+ exitCode,
301
+ timedOut ? timeoutMs : undefined,
302
+ );
303
+ }
304
+ try {
305
+ return parseBunAuditStreams(stdoutBytes, stderrBytes, exitCode);
306
+ } catch (err) {
307
+ // A self-inflicted kill can leave banner or truncated JSON instead of
308
+ // empty streams — that is a timeout, not malformed data, so it must be
309
+ // retried like any other unavailable result.
310
+ if (timedOut) {
311
+ throw new BunAuditUnavailableError(stdoutBytes, stderrBytes, exitCode, timeoutMs);
312
+ }
313
+ throw err;
314
+ }
315
+ }
316
+
317
+ // The advisory endpoint is a network call, and a single outage failed the whole
318
+ // security check — which gates the release job, so a blip blocked publishing
319
+ //. Only the "no JSON body" case retries;
320
+ // malformed-but-present JSON is a real parse problem and fails immediately.
321
+ const AUDIT_ATTEMPTS = 3;
322
+ const AUDIT_RETRY_DELAY_MS = 2_000;
323
+
324
+ // Extracted so the retry/timeout-budget policy is unit-testable without
325
+ // spawning the real `bun audit` subprocess — `now`/`sleep` are injectable for
326
+ // deterministic tests, real callers use the defaults (Date.now/Bun.sleep).
327
+ export async function retryWhileUnavailable<T>(
328
+ attempt: (remainingMs: number) => Promise<T>,
329
+ opts: {
330
+ readonly attempts: number;
331
+ readonly delayMs: number;
332
+ readonly timeoutMs: number;
333
+ readonly now?: () => number;
334
+ readonly sleep?: (ms: number) => Promise<void>;
335
+ },
336
+ ): Promise<T> {
337
+ const now = opts.now ?? Date.now;
338
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
339
+ // Each attempt gets the full remaining budget, not an even slice — a
340
+ // fast-failing 503 costs seconds and leaves the retry almost the whole
341
+ // budget, while a slow attempt shouldn't be cut off artificially short.
342
+ const deadline = now() + opts.timeoutMs;
343
+ let lastUnavailable: BunAuditUnavailableError | undefined;
344
+ for (let tries = 1; tries <= opts.attempts; tries++) {
345
+ const remaining = deadline - now();
346
+ if (remaining <= 0) {
347
+ const detail = lastUnavailable ? `\n${lastUnavailable.message}` : "";
348
+ throw new Error(
349
+ `timeout budget (${opts.timeoutMs}ms) exhausted before attempt ${tries}/${opts.attempts}${detail}`,
350
+ );
351
+ }
352
+ try {
353
+ return await attempt(remaining);
354
+ } catch (err) {
355
+ if (!(err instanceof BunAuditUnavailableError)) throw err;
356
+ lastUnavailable = err;
357
+ if (tries < opts.attempts) {
358
+ console.warn(
359
+ ` bun audit returned no JSON body (attempt ${tries}/${opts.attempts}) — retry in ${opts.delayMs}ms\n ${err.message}`,
360
+ );
361
+ await sleep(opts.delayMs * tries);
362
+ }
363
+ }
364
+ }
365
+ throw lastUnavailable;
366
+ }
367
+
368
+ // Upper bound for the rare hang, not the normal case — the audit is usually fast.
369
+ export const AUDIT_TIMEOUT_MS = 480_000;
370
+
371
+ export async function runBunAudit(
372
+ root: string,
373
+ timeoutMs = AUDIT_TIMEOUT_MS,
374
+ ): Promise<RawAuditReport> {
375
+ try {
376
+ return await retryWhileUnavailable((remaining) => runBunAuditOnce(root, remaining), {
377
+ attempts: AUDIT_ATTEMPTS,
378
+ delayMs: AUDIT_RETRY_DELAY_MS,
379
+ timeoutMs,
380
+ });
381
+ } catch (err) {
382
+ if (err instanceof BunAuditUnavailableError) {
383
+ throw new Error(
384
+ `bun audit failed in ${root} after ${AUDIT_ATTEMPTS} attempts:\n${err.message}`,
385
+ );
386
+ }
387
+ const detail = err instanceof Error ? err.message : String(err);
388
+ throw new Error(`bun audit failed in ${root}:\n${detail}`);
389
+ }
390
+ }
package/src/changes.json CHANGED
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "comment-lang needs two lower-case markers, scans HEAD in touched mode and recognises division after keywords",
6
+ "detail": "The short German markers now match case-sensitively in lower case and need two hits, so a licence header such as \"MIT License\" no longer counts; an umlaut alone still does. The scanner treats a slash after contextual keywords (from, type, get, of, ...) and after the non-null postfix as division instead of a regex, so a German comment after \"width! / 2\" is no longer hidden. --touched compares the diff of mergeBase to HEAD and scans the file contents from HEAD, so the pre-push hook checks the commits being pushed rather than local uncommitted edits."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "improvement",
11
+ "title": "kumiko-guards gains licenses, security and outdated subcommands",
12
+ "detail": "bunx kumiko-guards licenses classifies installed packages against a licence allow/gray/deny policy, security runs bun audit (critical and high block, exceptions expiring within 14 days block), outdated fails on deprecated direct dependencies, warns with the reason for every package the registry did not answer and fails when it answered for none. All three scan the repo cwd sits in (installed packages from node_modules of the repo, its workspace members and every ancestor directory; the repo's bun.lock or the nearest ancestor's) and fail loudly when nothing is found. Each takes --report. They are not part of the default run or the checks bundle because they use the network. Exceptions need reason, reviewer and expiry; the package ships defaults and merges dependency-license-exceptions.json from the repo root and every ancestor whose node_modules was scanned, and dependency-security-exceptions.json from the repo root and the audited lockfile directory."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "guard-upgrade-state keeps legacy pendingManual steps and reports malformed ones",
18
+ "detail": "Marker entries written before pendingManual carried an id were dropped silently, so a repo with open manual steps passed the guard. The guard now derives the id from version and title like kumiko-upgrade does, and a structurally broken pendingManual entry is reported instead of filtered out."
19
+ },
20
+ {
21
+ "version": "0.364.0",
22
+ "type": "improvement",
23
+ "title": "kumiko-pre-push runs a gitleaks scan of origin/main..HEAD before the checks",
24
+ "detail": "When the gitleaks binary is installed, the hook runs `gitleaks git --redact --log-opts=origin/main..HEAD` in the repo root and blocks the push on a finding. A `.gitleaks.toml` in the repo root is picked up by gitleaks itself. Without the binary or without origin/main the hook only prints a warning and continues (install with `brew install gitleaks`)."
25
+ },
26
+ {
27
+ "version": "0.364.0",
28
+ "type": "fix",
29
+ "title": "text-field-stance guard test no longer fails on unrelated warnings in a shared test run",
30
+ "detail": "The test spied on console.warn globally and asserted zero calls. In a run that shares one process across test files, async warnings from other files (job-runner stop, mail egress) landed in the spy and turned main red. The assertion now only counts calls with the guard's own warn prefix. No change to the guard itself."
31
+ },
32
+ {
33
+ "version": "0.363.0",
34
+ "type": "improvement",
35
+ "title": "As-casts guard fails on baseline regressions",
36
+ "detail": "check-as-casts now returns violations for more suspect casts per file and target than the baseline, for unknown or missing @cast-boundary reasons in repo-local files (only when a baseline exists), and for a baseline with a drifted format. Repos without a baseline and sibling checkouts under ../ still only warn."
37
+ },
2
38
  {
3
39
  "version": "0.361.0",
4
40
  "type": "fix",
@@ -315,7 +351,7 @@
315
351
  "version": "0.282.0",
316
352
  "type": "improvement",
317
353
  "title": "Port the upgrade-state and runtime-isolation guards",
318
- "detail": "guard-upgrade-state and check-runtime-isolation ported from the private infra/guards package into the public @cosmicdrift/kumiko-guards package, rebuilt onto the public RepoCheck pattern with single-repo root resolution. guard-app-dockerfile, guard-doc-status, check-licenses, and check-security stay in infra/guards as CDGS-specific house rules (private registry scope, CDGS doc taxonomy, and CDGS license/security exception files with no equivalent consumer-facing mechanism in the public package) — they were deliberately not ported, not forgotten."
354
+ "detail": "guard-upgrade-state and check-runtime-isolation ported from the private infra guards package into the public @cosmicdrift/kumiko-guards package, rebuilt onto the public RepoCheck pattern with single-repo root resolution. guard-app-dockerfile, guard-doc-status, check-licenses, and check-security stay in infra guards as CDGS-specific house rules (private registry scope, CDGS doc taxonomy, and CDGS license/security exception files with no equivalent consumer-facing mechanism in the public package) — they were deliberately not ported, not forgotten."
319
355
  },
320
356
  {
321
357
  "version": "0.3.0",
@@ -331,8 +367,8 @@
331
367
  {
332
368
  "version": "0.3.0",
333
369
  "type": "improvement",
334
- "title": "Port the last five infra/guards guards",
335
- "detail": "app-feature-structure, lib-test-coverage, i18n-locale-terminology, feature-integration-tests, and test-stack-drift ported from the private infra/guards package into the public @cosmicdrift/kumiko-guards package. The latter two were rebuilt from ad-hoc infra scripts onto the public RepoCheck pattern; the other three are literal AstGuard ports."
370
+ "title": "Port the last five infra guards guards",
371
+ "detail": "app-feature-structure, lib-test-coverage, i18n-locale-terminology, feature-integration-tests, and test-stack-drift ported from the private infra guards package into the public @cosmicdrift/kumiko-guards package. The latter two were rebuilt from ad-hoc infra scripts onto the public RepoCheck pattern; the other three are literal AstGuard ports."
336
372
  },
337
373
  {
338
374
  "version": "0.3.0",
@@ -342,12 +378,12 @@
342
378
  {
343
379
  "version": "0.2.0",
344
380
  "type": "improvement",
345
- "title": "Migrate remaining framework-semantic guards out of infra/guards"
381
+ "title": "Migrate remaining framework-semantic guards out of infra guards"
346
382
  },
347
383
  {
348
384
  "version": "0.2.0",
349
385
  "type": "improvement",
350
- "title": "Add runner parity with the private infra/guards package"
386
+ "title": "Add runner parity with the private infra guards package"
351
387
  },
352
388
  {
353
389
  "version": "0.1.1",