@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/script-packages-backend",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,17 +14,20 @@
14
14
  "test": "bun test"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.18.0",
18
- "@checkstack/common": "0.12.0",
19
- "@checkstack/script-packages-common": "0.1.0",
20
- "@checkstack/secrets-common": "0.0.1",
21
- "@checkstack/secrets-backend": "0.0.1",
22
- "@orpc/server": "^1.13.2",
17
+ "@checkstack/auth-common": "0.8.0",
18
+ "@checkstack/backend-api": "0.21.0",
19
+ "@checkstack/common": "0.13.0",
20
+ "@checkstack/notification-common": "1.3.0",
21
+ "@checkstack/sdk": "0.95.0",
22
+ "@checkstack/script-packages-common": "0.3.0",
23
+ "@checkstack/secrets-common": "0.2.0",
24
+ "@checkstack/secrets-backend": "0.2.0",
25
+ "@orpc/server": "^1.14.4",
23
26
  "drizzle-orm": "^0.45.0",
24
27
  "zod": "^4.0.0"
25
28
  },
26
29
  "devDependencies": {
27
- "@checkstack/scripts": "0.3.4",
30
+ "@checkstack/scripts": "0.4.0",
28
31
  "@checkstack/tsconfig": "0.0.7",
29
32
  "drizzle-kit": "^0.31.10",
30
33
  "typescript": "^5.7.2"
@@ -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
+ }