@checkstack/script-packages-backend 0.2.0 → 0.3.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/CHANGELOG.md +100 -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 +9 -6
- 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,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { AuditAdvisory, AuditSeverity } from "@checkstack/script-packages-common";
|
|
3
|
+
import { advisoryKey, computeAuditDelta } from "./audit-delta";
|
|
4
|
+
|
|
5
|
+
function adv(
|
|
6
|
+
packageName: string,
|
|
7
|
+
advisoryId: string,
|
|
8
|
+
severity: AuditSeverity,
|
|
9
|
+
): AuditAdvisory {
|
|
10
|
+
return {
|
|
11
|
+
packageName,
|
|
12
|
+
advisoryId,
|
|
13
|
+
severity,
|
|
14
|
+
title: `${packageName} ${advisoryId}`,
|
|
15
|
+
vulnerableVersions: "<1",
|
|
16
|
+
url: null,
|
|
17
|
+
cvssScore: null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const THRESHOLD: AuditSeverity = "moderate";
|
|
22
|
+
|
|
23
|
+
describe("computeAuditDelta", () => {
|
|
24
|
+
it("notifies newly-appeared advisories at or above the threshold", () => {
|
|
25
|
+
const result = computeAuditDelta({
|
|
26
|
+
previous: [],
|
|
27
|
+
current: [adv("lodash", "1", "high"), adv("a", "2", "low")],
|
|
28
|
+
threshold: THRESHOLD,
|
|
29
|
+
});
|
|
30
|
+
expect(result.toNotify.map((a) => a.packageName)).toEqual(["lodash"]);
|
|
31
|
+
expect(result.newlyAppeared.sort()).toEqual([
|
|
32
|
+
advisoryKey({ packageName: "a", advisoryId: "2" }),
|
|
33
|
+
advisoryKey({ packageName: "lodash", advisoryId: "1" }),
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does NOT notify a newly-appeared advisory below the threshold", () => {
|
|
38
|
+
const result = computeAuditDelta({
|
|
39
|
+
previous: [],
|
|
40
|
+
current: [adv("a", "1", "low")],
|
|
41
|
+
threshold: THRESHOLD,
|
|
42
|
+
});
|
|
43
|
+
expect(result.toNotify).toEqual([]);
|
|
44
|
+
expect(result.newlyAppeared).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("suppresses re-notify on an unchanged set (the spam guard)", () => {
|
|
48
|
+
const set = [adv("lodash", "1", "high"), adv("zod", "2", "critical")];
|
|
49
|
+
const result = computeAuditDelta({
|
|
50
|
+
previous: set,
|
|
51
|
+
current: set,
|
|
52
|
+
threshold: THRESHOLD,
|
|
53
|
+
});
|
|
54
|
+
expect(result.toNotify).toEqual([]);
|
|
55
|
+
expect(result.newlyAppeared).toEqual([]);
|
|
56
|
+
expect(result.escalated).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("notifies on severity escalation across the threshold", () => {
|
|
60
|
+
const result = computeAuditDelta({
|
|
61
|
+
previous: [adv("lodash", "1", "low")],
|
|
62
|
+
current: [adv("lodash", "1", "high")],
|
|
63
|
+
threshold: THRESHOLD,
|
|
64
|
+
});
|
|
65
|
+
expect(result.toNotify.map((a) => a.packageName)).toEqual(["lodash"]);
|
|
66
|
+
expect(result.escalated).toEqual([
|
|
67
|
+
advisoryKey({ packageName: "lodash", advisoryId: "1" }),
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("notifies on a within-threshold escalation (high -> critical)", () => {
|
|
72
|
+
const result = computeAuditDelta({
|
|
73
|
+
previous: [adv("lodash", "1", "high")],
|
|
74
|
+
current: [adv("lodash", "1", "critical")],
|
|
75
|
+
threshold: THRESHOLD,
|
|
76
|
+
});
|
|
77
|
+
expect(result.toNotify).toHaveLength(1);
|
|
78
|
+
expect(result.escalated).toHaveLength(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not notify a below-threshold escalation (low -> moderate is at threshold; low->low-ish stays quiet)", () => {
|
|
82
|
+
// escalated but still below threshold: bump low -> ... still below.
|
|
83
|
+
const result = computeAuditDelta({
|
|
84
|
+
previous: [adv("a", "1", "low")],
|
|
85
|
+
current: [adv("a", "1", "low")], // unchanged
|
|
86
|
+
threshold: "high",
|
|
87
|
+
});
|
|
88
|
+
expect(result.toNotify).toEqual([]);
|
|
89
|
+
expect(result.escalated).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("never notifies on de-escalation", () => {
|
|
93
|
+
const result = computeAuditDelta({
|
|
94
|
+
previous: [adv("lodash", "1", "critical")],
|
|
95
|
+
current: [adv("lodash", "1", "moderate")],
|
|
96
|
+
threshold: THRESHOLD,
|
|
97
|
+
});
|
|
98
|
+
expect(result.toNotify).toEqual([]);
|
|
99
|
+
expect(result.escalated).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not notify when an advisory disappears", () => {
|
|
103
|
+
const result = computeAuditDelta({
|
|
104
|
+
previous: [adv("lodash", "1", "high")],
|
|
105
|
+
current: [],
|
|
106
|
+
threshold: THRESHOLD,
|
|
107
|
+
});
|
|
108
|
+
expect(result.toNotify).toEqual([]);
|
|
109
|
+
expect(result.newlyAppeared).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("sorts toNotify by severity (most severe first) then package name", () => {
|
|
113
|
+
const result = computeAuditDelta({
|
|
114
|
+
previous: [],
|
|
115
|
+
current: [
|
|
116
|
+
adv("b", "1", "moderate"),
|
|
117
|
+
adv("a", "2", "critical"),
|
|
118
|
+
adv("c", "3", "high"),
|
|
119
|
+
adv("a", "4", "moderate"),
|
|
120
|
+
],
|
|
121
|
+
threshold: THRESHOLD,
|
|
122
|
+
});
|
|
123
|
+
expect(
|
|
124
|
+
result.toNotify.map((a) => `${a.severity}:${a.packageName}`),
|
|
125
|
+
).toEqual(["critical:a", "high:c", "moderate:a", "moderate:b"]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUDIT_SEVERITY_RANK,
|
|
3
|
+
type AuditAdvisory,
|
|
4
|
+
type AuditSeverity,
|
|
5
|
+
} from "@checkstack/script-packages-common";
|
|
6
|
+
import { meetsThreshold } from "./audit-parse";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pure delta logic for the vulnerability audit: given the previously-stored
|
|
10
|
+
* advisory set and the freshly-parsed one, decide which advisories warrant a
|
|
11
|
+
* NEW notification to `script-packages.manage` holders.
|
|
12
|
+
*
|
|
13
|
+
* Notify rules (locked product decision: threshold = `moderate`):
|
|
14
|
+
* - A NEWLY-APPEARED advisory (not in the previous set) at or above the
|
|
15
|
+
* threshold notifies.
|
|
16
|
+
* - An advisory whose severity ESCALATED across the threshold (was below,
|
|
17
|
+
* now at/above) notifies. (A within-threshold escalation, e.g.
|
|
18
|
+
* high -> critical, also notifies because operators want to know it got
|
|
19
|
+
* worse; a de-escalation never notifies.)
|
|
20
|
+
* - An UNCHANGED advisory never re-notifies. This is the spam guard: a daily
|
|
21
|
+
* run over a stable set produces zero notifications.
|
|
22
|
+
*
|
|
23
|
+
* The function is the suppression mechanism — the notification transport
|
|
24
|
+
* (`sendTransactional`) has no de-dup of its own, so "don't emit when
|
|
25
|
+
* nothing changed" lives here against the durably-stored previous set.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Identity of one advisory: `<package> <advisoryId>`. */
|
|
29
|
+
export function advisoryKey(advisory: {
|
|
30
|
+
packageName: string;
|
|
31
|
+
advisoryId: string;
|
|
32
|
+
}): string {
|
|
33
|
+
return `${advisory.packageName} ${advisory.advisoryId}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AuditDeltaInput {
|
|
37
|
+
/** Advisories stored from the previous run (any severity). */
|
|
38
|
+
previous: AuditAdvisory[];
|
|
39
|
+
/** Advisories parsed from the current run (any severity). */
|
|
40
|
+
current: AuditAdvisory[];
|
|
41
|
+
/** Notify on this severity and above. */
|
|
42
|
+
threshold: AuditSeverity;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AuditDeltaResult {
|
|
46
|
+
/**
|
|
47
|
+
* Advisories to notify about this run: newly-appeared or severity-escalated
|
|
48
|
+
* across/within the threshold. Sorted by severity (most severe first) then
|
|
49
|
+
* package name for stable rendering.
|
|
50
|
+
*/
|
|
51
|
+
toNotify: AuditAdvisory[];
|
|
52
|
+
/** Advisory keys that newly appeared (regardless of severity). */
|
|
53
|
+
newlyAppeared: string[];
|
|
54
|
+
/** Advisory keys whose severity increased since the previous run. */
|
|
55
|
+
escalated: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function computeAuditDelta({
|
|
59
|
+
previous,
|
|
60
|
+
current,
|
|
61
|
+
threshold,
|
|
62
|
+
}: AuditDeltaInput): AuditDeltaResult {
|
|
63
|
+
const prevBySeverity = new Map<string, AuditSeverity>();
|
|
64
|
+
for (const a of previous) prevBySeverity.set(advisoryKey(a), a.severity);
|
|
65
|
+
|
|
66
|
+
const toNotify: AuditAdvisory[] = [];
|
|
67
|
+
const newlyAppeared: string[] = [];
|
|
68
|
+
const escalated: string[] = [];
|
|
69
|
+
|
|
70
|
+
for (const adv of current) {
|
|
71
|
+
const key = advisoryKey(adv);
|
|
72
|
+
const prevSeverity = prevBySeverity.get(key);
|
|
73
|
+
const meetsNow = meetsThreshold(adv.severity, threshold);
|
|
74
|
+
|
|
75
|
+
if (prevSeverity === undefined) {
|
|
76
|
+
newlyAppeared.push(key);
|
|
77
|
+
// A brand-new advisory notifies only if it meets the threshold.
|
|
78
|
+
if (meetsNow) toNotify.push(adv);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const increased =
|
|
83
|
+
AUDIT_SEVERITY_RANK[adv.severity] > AUDIT_SEVERITY_RANK[prevSeverity];
|
|
84
|
+
if (increased) {
|
|
85
|
+
escalated.push(key);
|
|
86
|
+
// An escalation notifies when the (now-higher) severity meets the
|
|
87
|
+
// threshold. (If it escalated but is still below threshold, stay quiet.)
|
|
88
|
+
if (meetsNow) toNotify.push(adv);
|
|
89
|
+
}
|
|
90
|
+
// Unchanged or de-escalated: never re-notify.
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toNotify.sort((a, b) => {
|
|
94
|
+
const rank = AUDIT_SEVERITY_RANK[b.severity] - AUDIT_SEVERITY_RANK[a.severity];
|
|
95
|
+
if (rank !== 0) return rank;
|
|
96
|
+
return a.packageName.localeCompare(b.packageName);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return { toNotify, newlyAppeared, escalated };
|
|
100
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { AuditAdvisory } from "@checkstack/script-packages-common";
|
|
3
|
+
import { countBySeverity, meetsThreshold, parseBunAudit } from "./audit-parse";
|
|
4
|
+
|
|
5
|
+
describe("parseBunAudit", () => {
|
|
6
|
+
it("parses the package-keyed advisory document", () => {
|
|
7
|
+
const stdout = JSON.stringify({
|
|
8
|
+
lodash: [
|
|
9
|
+
{
|
|
10
|
+
id: 1106913,
|
|
11
|
+
url: "https://github.com/advisories/GHSA-35jh-r3h4-6jhm",
|
|
12
|
+
title: "Command Injection in lodash",
|
|
13
|
+
severity: "high",
|
|
14
|
+
vulnerable_versions: "<4.17.21",
|
|
15
|
+
cvss: { score: 7.2, vectorString: "CVSS:3.1/..." },
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
const { advisories } = parseBunAudit(stdout);
|
|
20
|
+
expect(advisories).toHaveLength(1);
|
|
21
|
+
expect(advisories[0]).toEqual({
|
|
22
|
+
packageName: "lodash",
|
|
23
|
+
advisoryId: "1106913",
|
|
24
|
+
title: "Command Injection in lodash",
|
|
25
|
+
severity: "high",
|
|
26
|
+
vulnerableVersions: "<4.17.21",
|
|
27
|
+
url: "https://github.com/advisories/GHSA-35jh-r3h4-6jhm",
|
|
28
|
+
cvssScore: 7.2,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("treats {} (clean tree) and [] (no deps) as no advisories", () => {
|
|
33
|
+
expect(parseBunAudit("{}").advisories).toEqual([]);
|
|
34
|
+
expect(parseBunAudit("[]").advisories).toEqual([]);
|
|
35
|
+
expect(parseBunAudit(" ").advisories).toEqual([]);
|
|
36
|
+
expect(parseBunAudit("").advisories).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("sorts deterministically by package then advisory id", () => {
|
|
40
|
+
const stdout = JSON.stringify({
|
|
41
|
+
zod: [{ id: 200, severity: "low", vulnerable_versions: "<1" }],
|
|
42
|
+
lodash: [
|
|
43
|
+
{ id: 30, severity: "high", vulnerable_versions: "<2" },
|
|
44
|
+
{ id: 10, severity: "moderate", vulnerable_versions: "<3" },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
const { advisories } = parseBunAudit(stdout);
|
|
48
|
+
expect(advisories.map((a) => `${a.packageName}:${a.advisoryId}`)).toEqual([
|
|
49
|
+
"lodash:10",
|
|
50
|
+
"lodash:30",
|
|
51
|
+
"zod:200",
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("maps 'medium' to 'moderate' and coerces unknown severities to 'low'", () => {
|
|
56
|
+
const stdout = JSON.stringify({
|
|
57
|
+
a: [{ id: 1, severity: "medium", vulnerable_versions: "<1" }],
|
|
58
|
+
b: [{ id: 2, severity: "spooky", vulnerable_versions: "<1" }],
|
|
59
|
+
});
|
|
60
|
+
const { advisories } = parseBunAudit(stdout);
|
|
61
|
+
expect(advisories.find((x) => x.packageName === "a")?.severity).toBe(
|
|
62
|
+
"moderate",
|
|
63
|
+
);
|
|
64
|
+
expect(advisories.find((x) => x.packageName === "b")?.severity).toBe("low");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("dedups the same (package, advisory) and skips entries with no id", () => {
|
|
68
|
+
const stdout = JSON.stringify({
|
|
69
|
+
lodash: [
|
|
70
|
+
{ id: 5, severity: "high", vulnerable_versions: "<1" },
|
|
71
|
+
{ id: 5, severity: "high", vulnerable_versions: "<1" },
|
|
72
|
+
{ severity: "high", vulnerable_versions: "<1" },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
const { advisories } = parseBunAudit(stdout);
|
|
76
|
+
expect(advisories).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("tolerates missing optional fields and null cvss", () => {
|
|
80
|
+
const stdout = JSON.stringify({
|
|
81
|
+
pkg: [{ id: 9, severity: "critical" }],
|
|
82
|
+
});
|
|
83
|
+
const { advisories } = parseBunAudit(stdout);
|
|
84
|
+
expect(advisories[0]).toMatchObject({
|
|
85
|
+
title: "",
|
|
86
|
+
vulnerableVersions: "",
|
|
87
|
+
url: null,
|
|
88
|
+
cvssScore: null,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("throws on non-JSON output", () => {
|
|
93
|
+
expect(() => parseBunAudit("error: registry unreachable")).toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("countBySeverity", () => {
|
|
98
|
+
it("counts each bucket and always returns all four", () => {
|
|
99
|
+
const advisories: AuditAdvisory[] = (
|
|
100
|
+
[
|
|
101
|
+
["a", "1", "low"],
|
|
102
|
+
["b", "2", "high"],
|
|
103
|
+
["c", "3", "high"],
|
|
104
|
+
["d", "4", "critical"],
|
|
105
|
+
] as const
|
|
106
|
+
).map(([packageName, advisoryId, severity]) => ({
|
|
107
|
+
packageName,
|
|
108
|
+
advisoryId,
|
|
109
|
+
severity,
|
|
110
|
+
title: "",
|
|
111
|
+
vulnerableVersions: "",
|
|
112
|
+
url: null,
|
|
113
|
+
cvssScore: null,
|
|
114
|
+
}));
|
|
115
|
+
const counts = countBySeverity(advisories);
|
|
116
|
+
expect(counts).toEqual({ low: 1, moderate: 0, high: 2, critical: 1 });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("meetsThreshold", () => {
|
|
121
|
+
it("compares by ordinal rank", () => {
|
|
122
|
+
expect(meetsThreshold("low", "moderate")).toBe(false);
|
|
123
|
+
expect(meetsThreshold("moderate", "moderate")).toBe(true);
|
|
124
|
+
expect(meetsThreshold("high", "moderate")).toBe(true);
|
|
125
|
+
expect(meetsThreshold("critical", "high")).toBe(true);
|
|
126
|
+
expect(meetsThreshold("moderate", "high")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
AUDIT_SEVERITY_RANK,
|
|
4
|
+
type AuditAdvisory,
|
|
5
|
+
type AuditSeverity,
|
|
6
|
+
} from "@checkstack/script-packages-common";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse `bun audit --json` stdout into a normalised, deterministically-sorted
|
|
10
|
+
* advisory list.
|
|
11
|
+
*
|
|
12
|
+
* `bun audit --json` writes an object keyed by package name, each value an
|
|
13
|
+
* array of advisories:
|
|
14
|
+
* ```json
|
|
15
|
+
* { "lodash": [ { "id": 123, "url": "...", "title": "...",
|
|
16
|
+
* "severity": "high", "vulnerable_versions": "<4.17.21",
|
|
17
|
+
* "cvss": { "score": 7.2, "vectorString": "..." } } ] }
|
|
18
|
+
* ```
|
|
19
|
+
* A clean tree emits `{}` (or `[]` when there are no dependencies). The exit
|
|
20
|
+
* code is unreliable (Bun exits non-zero even on a clean tree in some
|
|
21
|
+
* versions), so callers MUST rely on this parse, never the exit status.
|
|
22
|
+
*
|
|
23
|
+
* Unknown severities are coerced to `low` rather than dropped, so a future
|
|
24
|
+
* registry value never silently hides a finding.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const KNOWN_SEVERITIES = new Set<AuditSeverity>([
|
|
28
|
+
"low",
|
|
29
|
+
"moderate",
|
|
30
|
+
"high",
|
|
31
|
+
"critical",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function normaliseSeverity(value: unknown): AuditSeverity {
|
|
35
|
+
if (typeof value === "string") {
|
|
36
|
+
const lower = value.toLowerCase();
|
|
37
|
+
// npm/GitHub sometimes use "medium" as a synonym for "moderate".
|
|
38
|
+
if (lower === "medium") return "moderate";
|
|
39
|
+
if (KNOWN_SEVERITIES.has(lower as AuditSeverity)) {
|
|
40
|
+
return lower as AuditSeverity;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return "low";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Raw advisory entry as Bun emits it. Lenient: tolerate missing fields. */
|
|
47
|
+
const RawAdvisorySchema = z.object({
|
|
48
|
+
id: z.union([z.number(), z.string()]).optional(),
|
|
49
|
+
url: z.string().optional().nullable(),
|
|
50
|
+
title: z.string().optional().nullable(),
|
|
51
|
+
severity: z.string().optional().nullable(),
|
|
52
|
+
vulnerable_versions: z.string().optional().nullable(),
|
|
53
|
+
cvss: z
|
|
54
|
+
.object({ score: z.number().optional().nullable() })
|
|
55
|
+
.optional()
|
|
56
|
+
.nullable(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The whole document: `{ [packageName]: RawAdvisory[] }`. An empty-deps tree
|
|
61
|
+
* yields `[]`, which we treat as "no advisories".
|
|
62
|
+
*/
|
|
63
|
+
const AuditDocumentSchema = z.union([
|
|
64
|
+
z.record(z.string(), z.array(RawAdvisorySchema)),
|
|
65
|
+
z.array(z.unknown()),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
export interface ParseAuditResult {
|
|
69
|
+
advisories: AuditAdvisory[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compare advisories deterministically so equal sets serialise identically:
|
|
74
|
+
* package name, then advisory id.
|
|
75
|
+
*/
|
|
76
|
+
function compareAdvisories(a: AuditAdvisory, b: AuditAdvisory): number {
|
|
77
|
+
if (a.packageName !== b.packageName) {
|
|
78
|
+
return a.packageName.localeCompare(b.packageName);
|
|
79
|
+
}
|
|
80
|
+
return a.advisoryId.localeCompare(b.advisoryId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseBunAudit(stdout: string): ParseAuditResult {
|
|
84
|
+
const trimmed = stdout.trim();
|
|
85
|
+
if (trimmed.length === 0) return { advisories: [] };
|
|
86
|
+
|
|
87
|
+
let json: unknown;
|
|
88
|
+
try {
|
|
89
|
+
json = JSON.parse(trimmed);
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error("bun audit produced output that is not valid JSON.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const doc = AuditDocumentSchema.parse(json);
|
|
95
|
+
if (Array.isArray(doc)) return { advisories: [] };
|
|
96
|
+
|
|
97
|
+
const advisories: AuditAdvisory[] = [];
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
for (const [packageName, rawList] of Object.entries(doc)) {
|
|
100
|
+
for (const raw of rawList) {
|
|
101
|
+
const advisoryId =
|
|
102
|
+
raw.id === undefined || raw.id === null ? "" : String(raw.id);
|
|
103
|
+
if (advisoryId.length === 0) continue;
|
|
104
|
+
// Dedup on (package, advisory) so a registry returning the same
|
|
105
|
+
// advisory twice can't double-count.
|
|
106
|
+
const dedupKey = `${packageName} ${advisoryId}`;
|
|
107
|
+
if (seen.has(dedupKey)) continue;
|
|
108
|
+
seen.add(dedupKey);
|
|
109
|
+
|
|
110
|
+
advisories.push({
|
|
111
|
+
packageName,
|
|
112
|
+
advisoryId,
|
|
113
|
+
title: raw.title ?? "",
|
|
114
|
+
severity: normaliseSeverity(raw.severity),
|
|
115
|
+
vulnerableVersions: raw.vulnerable_versions ?? "",
|
|
116
|
+
url: raw.url ?? null,
|
|
117
|
+
cvssScore:
|
|
118
|
+
raw.cvss && typeof raw.cvss.score === "number"
|
|
119
|
+
? raw.cvss.score
|
|
120
|
+
: null,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
advisories.sort(compareAdvisories);
|
|
126
|
+
return { advisories };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Count advisories by severity (all four buckets always present). */
|
|
130
|
+
export function countBySeverity(advisories: AuditAdvisory[]): {
|
|
131
|
+
low: number;
|
|
132
|
+
moderate: number;
|
|
133
|
+
high: number;
|
|
134
|
+
critical: number;
|
|
135
|
+
} {
|
|
136
|
+
const counts = { low: 0, moderate: 0, high: 0, critical: 0 };
|
|
137
|
+
for (const a of advisories) counts[a.severity] += 1;
|
|
138
|
+
return counts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** True when `severity` is at or above `threshold`. */
|
|
142
|
+
export function meetsThreshold(
|
|
143
|
+
severity: AuditSeverity,
|
|
144
|
+
threshold: AuditSeverity,
|
|
145
|
+
): boolean {
|
|
146
|
+
return AUDIT_SEVERITY_RANK[severity] >= AUDIT_SEVERITY_RANK[threshold];
|
|
147
|
+
}
|