@checkstack/script-packages-backend 0.2.1 → 0.3.1
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/CHANGELOG.md +106 -0
- package/drizzle/0002_dry_sue_storm.sql +27 -0
- package/drizzle/meta/0002_snapshot.json +666 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +11 -8
- package/src/audit-delta.test.ts +127 -0
- package/src/audit-delta.ts +100 -0
- package/src/audit-parse.test.ts +128 -0
- package/src/audit-parse.ts +147 -0
- package/src/audit-runner.test.ts +230 -0
- package/src/audit-runner.ts +224 -0
- package/src/audit-scanner.test.ts +101 -0
- package/src/audit-scanner.ts +156 -0
- package/src/hooks.ts +14 -0
- package/src/index.ts +264 -3
- package/src/router.ts +49 -0
- package/src/sandbox-policy-router.test.ts +105 -0
- package/src/sandbox-policy.test.ts +119 -0
- package/src/sandbox-policy.ts +68 -0
- package/src/sandbox-startup-log.test.ts +128 -0
- package/src/sandbox-startup-log.ts +83 -0
- package/src/schema.ts +53 -1
- package/src/sdk-types-route.test.ts +121 -0
- package/src/sdk-types-route.ts +137 -0
- package/src/stores.ts +216 -1
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
AuditAdvisory,
|
|
4
|
+
AuditState,
|
|
5
|
+
} from "@checkstack/script-packages-common";
|
|
6
|
+
import { createAuditRunner, type AuditRunnerDeps } from "./audit-runner";
|
|
7
|
+
import { advisoryKey } from "./audit-delta";
|
|
8
|
+
|
|
9
|
+
function adv(
|
|
10
|
+
packageName: string,
|
|
11
|
+
advisoryId: string,
|
|
12
|
+
severity: AuditAdvisory["severity"],
|
|
13
|
+
): AuditAdvisory {
|
|
14
|
+
return {
|
|
15
|
+
packageName,
|
|
16
|
+
advisoryId,
|
|
17
|
+
severity,
|
|
18
|
+
title: `${packageName} ${advisoryId}`,
|
|
19
|
+
vulnerableVersions: "<1",
|
|
20
|
+
url: null,
|
|
21
|
+
cvssScore: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** In-memory audit store + notify capture for a given hash. */
|
|
26
|
+
function makeHarness(opts: {
|
|
27
|
+
lockfileHash: string | null;
|
|
28
|
+
scanResult: AuditAdvisory[];
|
|
29
|
+
managers?: string[];
|
|
30
|
+
initialStored?: AuditAdvisory[];
|
|
31
|
+
initialNotifiedKeys?: Set<string>;
|
|
32
|
+
}) {
|
|
33
|
+
const stored = new Map<string, AuditAdvisory>(); // key -> advisory
|
|
34
|
+
const notifiedFlags = new Set<string>();
|
|
35
|
+
for (const a of opts.initialStored ?? []) stored.set(advisoryKey(a), a);
|
|
36
|
+
for (const k of opts.initialNotifiedKeys ?? []) notifiedFlags.add(k);
|
|
37
|
+
|
|
38
|
+
let lastState: AuditState | null = null;
|
|
39
|
+
const notifies: { userId: string; body: string }[] = [];
|
|
40
|
+
let locked = false;
|
|
41
|
+
const completed: { lockfileHash: string | null; total: number }[] = [];
|
|
42
|
+
const pruneCalls: string[][] = [];
|
|
43
|
+
|
|
44
|
+
const deps: AuditRunnerDeps = {
|
|
45
|
+
installerLock: {
|
|
46
|
+
async tryInstallerLock() {
|
|
47
|
+
if (locked) return null;
|
|
48
|
+
locked = true;
|
|
49
|
+
return { release: async () => { locked = false; } };
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
auditStore: {
|
|
53
|
+
async advisoriesForHash() {
|
|
54
|
+
return [...stored.values()];
|
|
55
|
+
},
|
|
56
|
+
async notifiedSeverities() {
|
|
57
|
+
const m = new Map<string, AuditAdvisory["severity"]>();
|
|
58
|
+
for (const k of notifiedFlags) {
|
|
59
|
+
const a = stored.get(k);
|
|
60
|
+
if (a) m.set(k, a.severity);
|
|
61
|
+
}
|
|
62
|
+
return m;
|
|
63
|
+
},
|
|
64
|
+
async replaceForHash({ advisories, notifiedKeys }) {
|
|
65
|
+
stored.clear();
|
|
66
|
+
notifiedFlags.clear();
|
|
67
|
+
for (const a of advisories) {
|
|
68
|
+
stored.set(advisoryKey(a), a);
|
|
69
|
+
if (notifiedKeys.has(advisoryKey(a))) notifiedFlags.add(advisoryKey(a));
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async markNotified({ keys }) {
|
|
73
|
+
for (const k of keys) notifiedFlags.add(advisoryKey(k));
|
|
74
|
+
},
|
|
75
|
+
async pruneHashesNotIn(keepHashes: string[]) {
|
|
76
|
+
// Harness keys are not hash-scoped, so we record the call to assert
|
|
77
|
+
// the runner prunes superseded hashes (keeping only the current one).
|
|
78
|
+
pruneCalls.push(keepHashes);
|
|
79
|
+
},
|
|
80
|
+
async getState() {
|
|
81
|
+
return lastState!;
|
|
82
|
+
},
|
|
83
|
+
async recordRun({ lockfileHash, total, counts }) {
|
|
84
|
+
lastState = {
|
|
85
|
+
lastRunAt: new Date(),
|
|
86
|
+
lockfileHash,
|
|
87
|
+
total,
|
|
88
|
+
counts,
|
|
89
|
+
errorMessage: null,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
async recordError(message) {
|
|
93
|
+
lastState = {
|
|
94
|
+
lastRunAt: new Date(),
|
|
95
|
+
lockfileHash: null,
|
|
96
|
+
total: 0,
|
|
97
|
+
counts: { low: 0, moderate: 0, high: 0, critical: 0 },
|
|
98
|
+
errorMessage: message,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
async loadCurrent() {
|
|
103
|
+
return {
|
|
104
|
+
lockfileHash: opts.lockfileHash,
|
|
105
|
+
packages: [{ name: "lodash", version: "4.0.0", enabled: true }],
|
|
106
|
+
ignoreScripts: true,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
async scan() {
|
|
110
|
+
return { advisories: opts.scanResult };
|
|
111
|
+
},
|
|
112
|
+
async getUserIds() {
|
|
113
|
+
return (opts.managers ?? []).length > 0 ? ["u1", "u2"] : [];
|
|
114
|
+
},
|
|
115
|
+
async filterManagers() {
|
|
116
|
+
return opts.managers ?? [];
|
|
117
|
+
},
|
|
118
|
+
async notifyUser({ userId, body }) {
|
|
119
|
+
notifies.push({ userId, body });
|
|
120
|
+
},
|
|
121
|
+
async emitCompleted(input) {
|
|
122
|
+
completed.push(input);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
deps,
|
|
128
|
+
notifies,
|
|
129
|
+
completed,
|
|
130
|
+
stored,
|
|
131
|
+
notifiedFlags,
|
|
132
|
+
pruneCalls,
|
|
133
|
+
getState: () => lastState,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe("createAuditRunner", () => {
|
|
138
|
+
it("records all severities and notifies managers on new at/above-threshold advisories", async () => {
|
|
139
|
+
const h = makeHarness({
|
|
140
|
+
lockfileHash: "hash-1",
|
|
141
|
+
scanResult: [adv("lodash", "1", "high"), adv("a", "2", "low")],
|
|
142
|
+
managers: ["u1", "u2"],
|
|
143
|
+
});
|
|
144
|
+
const run = createAuditRunner(h.deps);
|
|
145
|
+
const summary = await run();
|
|
146
|
+
|
|
147
|
+
expect(summary).toMatchObject({ ran: true, lockfileHash: "hash-1", total: 2, notified: 1 });
|
|
148
|
+
// Prunes advisory rows for any superseded hash, keeping only the current one.
|
|
149
|
+
expect(h.pruneCalls).toEqual([["hash-1"]]);
|
|
150
|
+
// Recorded all (low counted too).
|
|
151
|
+
expect(h.getState()?.counts).toEqual({ low: 1, moderate: 0, high: 1, critical: 0 });
|
|
152
|
+
// Notified both managers, once each.
|
|
153
|
+
expect(h.notifies.map((n) => n.userId).sort()).toEqual(["u1", "u2"]);
|
|
154
|
+
// Completion signal emitted.
|
|
155
|
+
expect(h.completed).toEqual([{ lockfileHash: "hash-1", total: 2 }]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("does NOT re-notify an unchanged set on the second run (spam guard)", async () => {
|
|
159
|
+
const h = makeHarness({
|
|
160
|
+
lockfileHash: "hash-1",
|
|
161
|
+
scanResult: [adv("lodash", "1", "high")],
|
|
162
|
+
managers: ["u1"],
|
|
163
|
+
});
|
|
164
|
+
const run = createAuditRunner(h.deps);
|
|
165
|
+
await run();
|
|
166
|
+
expect(h.notifies).toHaveLength(1);
|
|
167
|
+
// Second run, identical scan result.
|
|
168
|
+
await run();
|
|
169
|
+
expect(h.notifies).toHaveLength(1); // no new notification
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("re-notifies on escalation of a previously-notified advisory", async () => {
|
|
173
|
+
const h = makeHarness({
|
|
174
|
+
lockfileHash: "hash-1",
|
|
175
|
+
scanResult: [adv("lodash", "1", "moderate")],
|
|
176
|
+
managers: ["u1"],
|
|
177
|
+
});
|
|
178
|
+
const run = createAuditRunner(h.deps);
|
|
179
|
+
await run();
|
|
180
|
+
expect(h.notifies).toHaveLength(1);
|
|
181
|
+
|
|
182
|
+
// Re-point the scan at an escalated severity for the same advisory.
|
|
183
|
+
h.deps.scan = async () => ({ advisories: [adv("lodash", "1", "critical")] });
|
|
184
|
+
await run();
|
|
185
|
+
expect(h.notifies).toHaveLength(2);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("refuses cleanly when the installer lock is held", async () => {
|
|
189
|
+
const h = makeHarness({
|
|
190
|
+
lockfileHash: "hash-1",
|
|
191
|
+
scanResult: [adv("lodash", "1", "high")],
|
|
192
|
+
managers: ["u1"],
|
|
193
|
+
});
|
|
194
|
+
// Hold the lock first.
|
|
195
|
+
await h.deps.installerLock.tryInstallerLock();
|
|
196
|
+
const run = createAuditRunner(h.deps);
|
|
197
|
+
const summary = await run();
|
|
198
|
+
expect(summary.ran).toBe(false);
|
|
199
|
+
expect(summary.reason).toContain("installer lock");
|
|
200
|
+
expect(h.notifies).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("records an empty result when nothing is installed", async () => {
|
|
204
|
+
const h = makeHarness({
|
|
205
|
+
lockfileHash: null,
|
|
206
|
+
scanResult: [],
|
|
207
|
+
managers: ["u1"],
|
|
208
|
+
});
|
|
209
|
+
const run = createAuditRunner(h.deps);
|
|
210
|
+
const summary = await run();
|
|
211
|
+
expect(summary).toMatchObject({ ran: true, lockfileHash: null, total: 0, notified: 0 });
|
|
212
|
+
expect(h.notifies).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("records an error and does not throw when the scan fails", async () => {
|
|
216
|
+
const h = makeHarness({
|
|
217
|
+
lockfileHash: "hash-1",
|
|
218
|
+
scanResult: [],
|
|
219
|
+
managers: ["u1"],
|
|
220
|
+
});
|
|
221
|
+
h.deps.scan = async () => {
|
|
222
|
+
throw new Error("bun audit boom");
|
|
223
|
+
};
|
|
224
|
+
const run = createAuditRunner(h.deps);
|
|
225
|
+
const summary = await run();
|
|
226
|
+
expect(summary.ran).toBe(false);
|
|
227
|
+
expect(summary.reason).toContain("boom");
|
|
228
|
+
expect(h.getState()?.errorMessage).toContain("boom");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUDIT_NOTIFY_THRESHOLD,
|
|
3
|
+
scriptPackagesRoutes,
|
|
4
|
+
type AuditAdvisory,
|
|
5
|
+
type AuditRunSummary,
|
|
6
|
+
} from "@checkstack/script-packages-common";
|
|
7
|
+
import { extractErrorMessage, resolveRoute } from "@checkstack/common";
|
|
8
|
+
import type { InstallerLock } from "./install-state-store";
|
|
9
|
+
import type { AuditStore } from "./stores";
|
|
10
|
+
import { countBySeverity } from "./audit-parse";
|
|
11
|
+
import { advisoryKey, computeAuditDelta } from "./audit-delta";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build an `auditNow()` callable that wires the pure audit pieces (scanner,
|
|
15
|
+
* delta logic, store) to the real stores + notification path, shared by the
|
|
16
|
+
* admin `auditNow` RPC and the scheduled recurring job (so the safety + delta
|
|
17
|
+
* logic lives in exactly one place). Mirrors {@link createBlobGcTrigger}.
|
|
18
|
+
*
|
|
19
|
+
* Election + mutual exclusion: takes the installer-election advisory lock for
|
|
20
|
+
* the whole pass, so a concurrent install / migration / blob-GC (which
|
|
21
|
+
* contend for the same lock) cannot run while the audit does, and vice versa.
|
|
22
|
+
* If the lock is held, the audit refuses cleanly and retries on the next tick.
|
|
23
|
+
*
|
|
24
|
+
* Notify suppression: only NEWLY-APPEARED or severity-ESCALATED advisories at
|
|
25
|
+
* or above the threshold notify; the durable per-advisory `notified` flag in
|
|
26
|
+
* the store carries across runs so a stable set never re-notifies (even
|
|
27
|
+
* across a redeploy). RECORD all severities regardless of the notify gate.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface AuditRunnerDeps {
|
|
31
|
+
installerLock: InstallerLock;
|
|
32
|
+
auditStore: AuditStore;
|
|
33
|
+
/** Current desired install state (the tree to audit). */
|
|
34
|
+
loadCurrent(): Promise<{
|
|
35
|
+
lockfileHash: string | null;
|
|
36
|
+
packages: { name: string; version: string; enabled: boolean }[];
|
|
37
|
+
ignoreScripts: boolean;
|
|
38
|
+
}>;
|
|
39
|
+
/** Run the actual `bun audit` scan against the resolved tree. */
|
|
40
|
+
scan(input: {
|
|
41
|
+
packages: { name: string; version: string; enabled: boolean }[];
|
|
42
|
+
ignoreScripts: boolean;
|
|
43
|
+
}): Promise<{ advisories: AuditAdvisory[] }>;
|
|
44
|
+
/** Enumerate candidate user ids (auth.getUsers ids). */
|
|
45
|
+
getUserIds(): Promise<string[]>;
|
|
46
|
+
/** Narrow to holders of `script-packages.manage`. */
|
|
47
|
+
filterManagers(userIds: string[]): Promise<string[]>;
|
|
48
|
+
/** Deliver one transactional notification to one user. */
|
|
49
|
+
notifyUser(input: {
|
|
50
|
+
userId: string;
|
|
51
|
+
title: string;
|
|
52
|
+
body: string;
|
|
53
|
+
importance: "info" | "warning" | "critical";
|
|
54
|
+
action?: { label: string; url: string };
|
|
55
|
+
}): Promise<void>;
|
|
56
|
+
/** Broadcast the audit-completed signal so settings pages refresh. */
|
|
57
|
+
emitCompleted(input: {
|
|
58
|
+
lockfileHash: string | null;
|
|
59
|
+
total: number;
|
|
60
|
+
}): Promise<void>;
|
|
61
|
+
logger?: { debug(msg: string): void; error(msg: string): void };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Importance for a notification batch, driven by its most-severe advisory. */
|
|
65
|
+
function importanceFor(
|
|
66
|
+
advisories: AuditAdvisory[],
|
|
67
|
+
): "info" | "warning" | "critical" {
|
|
68
|
+
if (advisories.some((a) => a.severity === "critical")) return "critical";
|
|
69
|
+
if (advisories.some((a) => a.severity === "high")) return "warning";
|
|
70
|
+
return "info";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildBody(advisories: AuditAdvisory[]): string {
|
|
74
|
+
const lines = advisories.map((a) => {
|
|
75
|
+
const link = a.url ? `[${a.title || a.advisoryId}](${a.url})` : a.title || a.advisoryId;
|
|
76
|
+
return `- **${a.packageName}** (${a.severity}): ${link}`;
|
|
77
|
+
});
|
|
78
|
+
return [
|
|
79
|
+
`${advisories.length} new vulnerability advisor${advisories.length === 1 ? "y" : "ies"} found in the installed script packages:`,
|
|
80
|
+
"",
|
|
81
|
+
...lines,
|
|
82
|
+
].join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createAuditRunner(
|
|
86
|
+
deps: AuditRunnerDeps,
|
|
87
|
+
): () => Promise<AuditRunSummary> {
|
|
88
|
+
return async function auditNow(): Promise<AuditRunSummary> {
|
|
89
|
+
const lock = await deps.installerLock.tryInstallerLock();
|
|
90
|
+
if (!lock) {
|
|
91
|
+
const reason =
|
|
92
|
+
"An install, storage migration, or blob GC holds the installer lock; skipping audit.";
|
|
93
|
+
deps.logger?.debug(reason);
|
|
94
|
+
return { ran: false, reason, lockfileHash: null, total: 0, notified: 0 };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const current = await deps.loadCurrent();
|
|
99
|
+
const lockfileHash = current.lockfileHash;
|
|
100
|
+
|
|
101
|
+
// Nothing installed → record an empty, error-free result.
|
|
102
|
+
if (!lockfileHash) {
|
|
103
|
+
await deps.auditStore.recordRun({
|
|
104
|
+
lockfileHash: null,
|
|
105
|
+
total: 0,
|
|
106
|
+
counts: { low: 0, moderate: 0, high: 0, critical: 0 },
|
|
107
|
+
});
|
|
108
|
+
await deps.emitCompleted({ lockfileHash: null, total: 0 });
|
|
109
|
+
return { ran: true, lockfileHash: null, total: 0, notified: 0 };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const previous = await deps.auditStore.advisoriesForHash(lockfileHash);
|
|
113
|
+
const alreadyNotified =
|
|
114
|
+
await deps.auditStore.notifiedSeverities(lockfileHash);
|
|
115
|
+
|
|
116
|
+
const { advisories } = await deps.scan({
|
|
117
|
+
packages: current.packages,
|
|
118
|
+
ignoreScripts: current.ignoreScripts,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const delta = computeAuditDelta({
|
|
122
|
+
previous,
|
|
123
|
+
current: advisories,
|
|
124
|
+
threshold: AUDIT_NOTIFY_THRESHOLD,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Drop advisories we've already notified at (at least) their current
|
|
128
|
+
// severity, so a redeploy with the same set never re-spams.
|
|
129
|
+
const toNotify = delta.toNotify.filter((a) => {
|
|
130
|
+
const prev = alreadyNotified.get(advisoryKey(a));
|
|
131
|
+
// Re-notify only if not yet notified, or it escalated beyond what we
|
|
132
|
+
// last told them about (handled by the delta's escalation check).
|
|
133
|
+
return prev === undefined || prev !== a.severity;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Persist findings first (so the read path is correct even if notify
|
|
137
|
+
// fails). Carry `notified` forward for advisories that stay unchanged.
|
|
138
|
+
const notifiedKeys = new Set(alreadyNotified.keys());
|
|
139
|
+
await deps.auditStore.replaceForHash({
|
|
140
|
+
lockfileHash,
|
|
141
|
+
advisories,
|
|
142
|
+
notifiedKeys,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Drop advisory rows for superseded lockfile hashes so they don't
|
|
146
|
+
// accumulate forever as the installed tree changes. Only the current
|
|
147
|
+
// hash is queryable (getAuditState reads the current hash), so anything
|
|
148
|
+
// else is dead data. Best-effort: a prune failure must not fail the run.
|
|
149
|
+
await deps.auditStore
|
|
150
|
+
.pruneHashesNotIn([lockfileHash])
|
|
151
|
+
.catch((error) => {
|
|
152
|
+
deps.logger?.error(
|
|
153
|
+
`Audit: pruning superseded advisory hashes failed: ${extractErrorMessage(error)}`,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const counts = countBySeverity(advisories);
|
|
158
|
+
await deps.auditStore.recordRun({
|
|
159
|
+
lockfileHash,
|
|
160
|
+
total: advisories.length,
|
|
161
|
+
counts,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Notify holders about the new/escalated advisories (best-effort).
|
|
165
|
+
if (toNotify.length > 0) {
|
|
166
|
+
try {
|
|
167
|
+
const userIds = await deps.getUserIds();
|
|
168
|
+
const managers =
|
|
169
|
+
userIds.length > 0 ? await deps.filterManagers(userIds) : [];
|
|
170
|
+
if (managers.length > 0) {
|
|
171
|
+
const body = buildBody(toNotify);
|
|
172
|
+
const importance = importanceFor(toNotify);
|
|
173
|
+
for (const userId of managers) {
|
|
174
|
+
await deps
|
|
175
|
+
.notifyUser({
|
|
176
|
+
userId,
|
|
177
|
+
title: `Script packages: ${toNotify.length} new vulnerability advisor${toNotify.length === 1 ? "y" : "ies"}`,
|
|
178
|
+
body,
|
|
179
|
+
importance,
|
|
180
|
+
action: {
|
|
181
|
+
label: "Review advisories",
|
|
182
|
+
url: resolveRoute(scriptPackagesRoutes.routes.settings),
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
.catch((error) => {
|
|
186
|
+
deps.logger?.error(
|
|
187
|
+
`Audit notify to ${userId} failed: ${extractErrorMessage(error)}`,
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Mark the notified advisories so the next pass doesn't re-notify.
|
|
193
|
+
await deps.auditStore.markNotified({
|
|
194
|
+
lockfileHash,
|
|
195
|
+
keys: toNotify.map((a) => ({
|
|
196
|
+
packageName: a.packageName,
|
|
197
|
+
advisoryId: a.advisoryId,
|
|
198
|
+
})),
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
deps.logger?.error(
|
|
202
|
+
`Audit notification step failed: ${extractErrorMessage(error)}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await deps.emitCompleted({ lockfileHash, total: advisories.length });
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
ran: true,
|
|
211
|
+
lockfileHash,
|
|
212
|
+
total: advisories.length,
|
|
213
|
+
notified: toNotify.length,
|
|
214
|
+
};
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const message = extractErrorMessage(error);
|
|
217
|
+
deps.logger?.error(`Audit pass failed: ${message}`);
|
|
218
|
+
await deps.auditStore.recordError(message).catch(() => {});
|
|
219
|
+
return { ran: false, reason: message, lockfileHash: null, total: 0, notified: 0 };
|
|
220
|
+
} finally {
|
|
221
|
+
await lock.release();
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
createAuditScanner,
|
|
7
|
+
type SpawnFn,
|
|
8
|
+
type SpawnOptions,
|
|
9
|
+
} from "./audit-scanner";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Unit tests for the audit scanner's spawn wiring. We inject a fake `spawn`
|
|
13
|
+
* so no real process runs - the property under test is that the audit reuses
|
|
14
|
+
* the installer's SHARED cache dir (via `BUN_INSTALL_CACHE_DIR`) rather than
|
|
15
|
+
* Bun's default global cache, so it cannot drift from the install path.
|
|
16
|
+
*/
|
|
17
|
+
describe("createAuditScanner spawn wiring", () => {
|
|
18
|
+
let work: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
work = await mkdtemp(path.join(tmpdir(), "cs-audit-scanner-"));
|
|
22
|
+
});
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await rm(work, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function fakeSpawn(stdouts: string[]): {
|
|
28
|
+
spawnFn: SpawnFn;
|
|
29
|
+
calls: SpawnOptions[];
|
|
30
|
+
} {
|
|
31
|
+
const calls: SpawnOptions[] = [];
|
|
32
|
+
let i = 0;
|
|
33
|
+
const spawnFn: SpawnFn = (options) => {
|
|
34
|
+
calls.push(options);
|
|
35
|
+
const out = stdouts[i] ?? "";
|
|
36
|
+
i += 1;
|
|
37
|
+
return {
|
|
38
|
+
stdout: new Response(out).body!,
|
|
39
|
+
stderr: new Response("").body!,
|
|
40
|
+
exited: Promise.resolve(0),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
return { spawnFn, calls };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("passes the shared cache dir as BUN_INSTALL_CACHE_DIR to install AND audit", async () => {
|
|
47
|
+
const cacheDir = path.join(work, "shared-cache");
|
|
48
|
+
// First spawn = install (empty stdout), second = audit (JSON advisories).
|
|
49
|
+
const auditJson = JSON.stringify({
|
|
50
|
+
lodash: [
|
|
51
|
+
{ id: 1, severity: "high", vulnerable_versions: "<4.17.21" },
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
const { spawnFn, calls } = fakeSpawn(["", auditJson]);
|
|
55
|
+
|
|
56
|
+
const scanner = createAuditScanner({
|
|
57
|
+
scratchDir: path.join(work, "scratch"),
|
|
58
|
+
cacheDir,
|
|
59
|
+
registry: {
|
|
60
|
+
registryUrl: "https://registry.npmjs.org/",
|
|
61
|
+
scopedRegistries: [],
|
|
62
|
+
authToken: undefined,
|
|
63
|
+
},
|
|
64
|
+
spawnFn,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await scanner.scan({
|
|
68
|
+
packages: [{ name: "lodash", version: "4.17.4", enabled: true }],
|
|
69
|
+
ignoreScripts: true,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(calls).toHaveLength(2);
|
|
73
|
+
// Both the install and the audit spawn must point at the shared cache dir.
|
|
74
|
+
expect(calls[0]!.env.BUN_INSTALL_CACHE_DIR).toBe(cacheDir);
|
|
75
|
+
expect(calls[1]!.env.BUN_INSTALL_CACHE_DIR).toBe(cacheDir);
|
|
76
|
+
// First spawn installs (with --ignore-scripts), second runs `audit --json`.
|
|
77
|
+
expect(calls[0]!.cmd).toContain("install");
|
|
78
|
+
expect(calls[0]!.cmd).toContain("--ignore-scripts");
|
|
79
|
+
expect(calls[1]!.cmd).toEqual([process.execPath, "audit", "--json"]);
|
|
80
|
+
// The parsed advisory comes through.
|
|
81
|
+
expect(result.advisories).toHaveLength(1);
|
|
82
|
+
expect(result.advisories[0]!.packageName).toBe("lodash");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("short-circuits (no spawn) when there are no enabled packages", async () => {
|
|
86
|
+
const { spawnFn, calls } = fakeSpawn([]);
|
|
87
|
+
const scanner = createAuditScanner({
|
|
88
|
+
scratchDir: path.join(work, "scratch"),
|
|
89
|
+
cacheDir: path.join(work, "cache"),
|
|
90
|
+
registry: {
|
|
91
|
+
registryUrl: "https://registry.npmjs.org/",
|
|
92
|
+
scopedRegistries: [],
|
|
93
|
+
authToken: undefined,
|
|
94
|
+
},
|
|
95
|
+
spawnFn,
|
|
96
|
+
});
|
|
97
|
+
const result = await scanner.scan({ packages: [], ignoreScripts: true });
|
|
98
|
+
expect(result.advisories).toEqual([]);
|
|
99
|
+
expect(calls).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
});
|