@cosmicdrift/kumiko-guards 0.363.0 → 0.365.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.
- package/README.md +28 -0
- package/package.json +2 -2
- package/src/_lib/agent-docs.ts +425 -0
- package/src/_lib/dependency-cli.ts +27 -0
- package/src/_lib/dependency-exceptions.ts +135 -0
- package/src/_lib/direct-dependencies.ts +80 -0
- package/src/_lib/installed-packages.ts +146 -0
- package/src/_lib/npm-registry.ts +168 -0
- package/src/_lib/push-root.ts +5 -3
- package/src/_lib/security-audit.ts +390 -0
- package/src/_lib/security-baseline.ts +14 -3
- package/src/changes.json +47 -5
- package/src/check-licenses.ts +288 -0
- package/src/check-outdated.ts +234 -0
- package/src/check-runtime-isolation.ts +3 -1
- package/src/check-security.ts +182 -0
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +27 -10
- package/src/dependency-license-exceptions.json +77 -0
- package/src/dependency-security-exceptions.json +4 -0
- package/src/guard-agent-docs-drift.ts +137 -0
- package/src/guard-comment-lang.ts +76 -40
- package/src/guard-direct-entity-writes.ts +3 -0
- package/src/guard-lib-test-coverage.ts +1 -1
- package/src/guard-no-direct-fs.ts +0 -1
- package/src/guard-pre-es-patterns.ts +3 -0
- package/src/guard-public-exports-changelog.ts +159 -24
- package/src/guard-test-timeouts.ts +5 -1
- package/src/guard-thin-wrappers.ts +9 -2
- package/src/guard-upgrade-state.ts +40 -11
- package/src/index.ts +1 -0
- package/src/pre-push.sh +20 -0
- package/src/run-repo-checks.ts +2 -0
|
@@ -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
|
+
}
|
|
@@ -27,7 +27,13 @@ export type SecurityBaselineLoad =
|
|
|
27
27
|
readonly findings: SecurityBaseline["findings"];
|
|
28
28
|
readonly hardFail: readonly string[];
|
|
29
29
|
}
|
|
30
|
-
| {
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: "invalid";
|
|
32
|
+
readonly file: string;
|
|
33
|
+
readonly reason: string;
|
|
34
|
+
/** Prose shown after the reason when the reason alone is only a code. */
|
|
35
|
+
readonly detail?: string;
|
|
36
|
+
};
|
|
31
37
|
|
|
32
38
|
// Compared only against the baseline file's `repo` field — a scoped npm
|
|
33
39
|
// package name (`@x/y`) is no longer a path segment, so it needs no
|
|
@@ -127,7 +133,12 @@ export function loadSecurityBaseline(repo: string, repoDir: string): SecurityBas
|
|
|
127
133
|
}
|
|
128
134
|
const parsed = parseSecurityBaseline(raw);
|
|
129
135
|
if (!parsed) {
|
|
130
|
-
return {
|
|
136
|
+
return {
|
|
137
|
+
kind: "invalid",
|
|
138
|
+
file,
|
|
139
|
+
reason: "unexpected_baseline_shape",
|
|
140
|
+
detail: "expected format/repo/generated/total/findings and a valid hardFail",
|
|
141
|
+
};
|
|
131
142
|
}
|
|
132
143
|
if (parsed.repo !== repo) {
|
|
133
144
|
return {
|
|
@@ -202,7 +213,7 @@ function invalidBaselineViolation(
|
|
|
202
213
|
return {
|
|
203
214
|
file: baseline.file,
|
|
204
215
|
line: 1,
|
|
205
|
-
message: `Security baseline unreadable or invalid: ${baseline.reason}. Fix the file; a broken baseline must not release any findings.`,
|
|
216
|
+
message: `Security baseline unreadable or invalid: ${baseline.reason}${baseline.detail === undefined ? "" : ` (${baseline.detail})`}. Fix the file; a broken baseline must not release any findings.`,
|
|
206
217
|
};
|
|
207
218
|
}
|
|
208
219
|
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "public-exports-changelog --base=<ref> checks drops against the baseline of the merge-base and matches exported names as whole identifiers",
|
|
6
|
+
"detail": "Check mode with --base=<ref> reads .kumiko-public-exports-baseline.json from the merge-base and sends every drop against it through the changelog/changeset ratchet, so a PR can no longer hide a removal by editing or deleting the baseline. An unknown ref is an error; a base without a baseline is a bootstrap. A changelog entry or open changeset covers a drop only when every dropped name appears in it as a whole identifier (getUserById no longer covers getUser). An unparsable changes.json is reported on stderr with its path. check-wt.sh, the worktree pre-push branch and the legacy check step pass the base."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.365.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "guard-test-timeouts ignores identifier test bodies and timeout-free options; push-root no longer trusts a lookalike folder name",
|
|
12
|
+
"detail": "An identifier passed as a test argument only counts as a timeout when its type is a number or has a timeout property, so Playwright's test(title, details, body) and options like { retry: 2 } pass. A push root that resolves to a git repo is judged by its canonical checkout name, not by its folder name. An unexpected security baseline shape keeps the reason unexpected_baseline_shape and adds a readable detail."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.364.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "comment-lang needs two lower-case markers, scans HEAD in touched mode and recognises division after keywords",
|
|
18
|
+
"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."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.364.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "kumiko-guards gains licenses, security and outdated subcommands",
|
|
24
|
+
"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."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.364.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "guard-upgrade-state keeps legacy pendingManual steps and reports malformed ones",
|
|
30
|
+
"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."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.364.0",
|
|
34
|
+
"type": "improvement",
|
|
35
|
+
"title": "kumiko-pre-push runs a gitleaks scan of origin/main..HEAD before the checks",
|
|
36
|
+
"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`)."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.364.0",
|
|
40
|
+
"type": "fix",
|
|
41
|
+
"title": "text-field-stance guard test no longer fails on unrelated warnings in a shared test run",
|
|
42
|
+
"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."
|
|
43
|
+
},
|
|
2
44
|
{
|
|
3
45
|
"version": "0.363.0",
|
|
4
46
|
"type": "improvement",
|
|
@@ -321,7 +363,7 @@
|
|
|
321
363
|
"version": "0.282.0",
|
|
322
364
|
"type": "improvement",
|
|
323
365
|
"title": "Port the upgrade-state and runtime-isolation guards",
|
|
324
|
-
"detail": "guard-upgrade-state and check-runtime-isolation ported from the private infra
|
|
366
|
+
"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."
|
|
325
367
|
},
|
|
326
368
|
{
|
|
327
369
|
"version": "0.3.0",
|
|
@@ -337,8 +379,8 @@
|
|
|
337
379
|
{
|
|
338
380
|
"version": "0.3.0",
|
|
339
381
|
"type": "improvement",
|
|
340
|
-
"title": "Port the last five infra
|
|
341
|
-
"detail": "app-feature-structure, lib-test-coverage, i18n-locale-terminology, feature-integration-tests, and test-stack-drift ported from the private infra
|
|
382
|
+
"title": "Port the last five infra guards guards",
|
|
383
|
+
"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."
|
|
342
384
|
},
|
|
343
385
|
{
|
|
344
386
|
"version": "0.3.0",
|
|
@@ -348,12 +390,12 @@
|
|
|
348
390
|
{
|
|
349
391
|
"version": "0.2.0",
|
|
350
392
|
"type": "improvement",
|
|
351
|
-
"title": "Migrate remaining framework-semantic guards out of infra
|
|
393
|
+
"title": "Migrate remaining framework-semantic guards out of infra guards"
|
|
352
394
|
},
|
|
353
395
|
{
|
|
354
396
|
"version": "0.2.0",
|
|
355
397
|
"type": "improvement",
|
|
356
|
-
"title": "Add runner parity with the private infra
|
|
398
|
+
"title": "Add runner parity with the private infra guards package"
|
|
357
399
|
},
|
|
358
400
|
{
|
|
359
401
|
"version": "0.1.1",
|