@rafinery/cli 0.11.0 → 0.12.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/bin/rafa.mjs +10 -0
- package/blueprint/.claude/agents/atlas.md +2 -2
- package/blueprint/.claude/agents/bloom.md +3 -2
- package/blueprint/.claude/commands/rafa.md +1 -1
- package/blueprint/.claude/rafa/contract.md +10 -4
- package/blueprint/.claude/skills/rafa-build/SKILL.md +5 -1
- package/blueprint/.claude/skills/rafa-improve/SKILL.md +17 -6
- package/blueprint/.claude/skills/rafa-okf/SKILL.md +1 -1
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +14 -1
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +9 -1
- package/blueprint/.claude/skills/rafa-security/SKILL.md +96 -0
- package/lib/audit/core.mjs +488 -0
- package/lib/audit.mjs +279 -0
- package/lib/blueprint.mjs +1 -0
- package/lib/distiller/doctrine.mjs +4 -4
- package/lib/doctor.mjs +16 -0
- package/lib/gate/compile.mjs +17 -13
- package/lib/init.mjs +3 -2
- package/lib/manifest.mjs +2 -1
- package/lib/mcp-client.mjs +3 -2
- package/lib/working-set.mjs +3 -2
- package/package.json +1 -1
- package/skills-bundle/skills-manifest.json +1 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// audit/core.mjs — the pure heart of `rafa audit` (finding shape rafa.audit/v1).
|
|
2
|
+
//
|
|
3
|
+
// Shared by TWO callers: lib/audit.mjs (the CLI command, local checkout) and
|
|
4
|
+
// apps/agent's merge-time security node (no checkout — lockfile fetched via
|
|
5
|
+
// GitHub MCC, OSV queried over HTTP). Because the agent deep-imports this file
|
|
6
|
+
// (the lib/distiller/doctrine.mjs precedent), it must stay dependency-free and
|
|
7
|
+
// I/O-free: pure (string|object) → object functions only — no fs, no
|
|
8
|
+
// child_process, no fetch. All I/O lives in the callers.
|
|
9
|
+
//
|
|
10
|
+
// NO ASSUMPTIONS: a field a source does not provide is null, never guessed; a
|
|
11
|
+
// tier that did not run says so structurally ({ran:false, reason}), never via
|
|
12
|
+
// an empty findings list.
|
|
13
|
+
|
|
14
|
+
export const AUDIT_SCHEMA = "rafa.audit/v1";
|
|
15
|
+
|
|
16
|
+
export const SEVERITIES = ["critical", "high", "moderate", "low", "info"];
|
|
17
|
+
const SEV_RANK = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
|
|
18
|
+
const TIER_ORDER = { dependency: 0, secret: 1, sast: 2 };
|
|
19
|
+
|
|
20
|
+
// Bounded-concurrency map — shared control flow for the OSV detail fetches
|
|
21
|
+
// (both callers cap at ~8). Pure orchestration; the fn does the I/O.
|
|
22
|
+
export async function mapConcurrent(items, limit, fn) {
|
|
23
|
+
const out = new Array(items.length);
|
|
24
|
+
let i = 0;
|
|
25
|
+
const workers = Array.from({ length: Math.min(limit, items.length) || 0 }, async () => {
|
|
26
|
+
while (i < items.length) {
|
|
27
|
+
const idx = i++;
|
|
28
|
+
out[idx] = await fn(items[idx], idx);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
await Promise.all(workers);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function severityFromCvss(score) {
|
|
36
|
+
if (typeof score !== "number" || Number.isNaN(score)) return null;
|
|
37
|
+
if (score >= 9) return "critical";
|
|
38
|
+
if (score >= 7) return "high";
|
|
39
|
+
if (score >= 4) return "moderate";
|
|
40
|
+
if (score > 0) return "low";
|
|
41
|
+
return "info";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const normSeverity = (label) => {
|
|
45
|
+
const l = String(label ?? "").toLowerCase();
|
|
46
|
+
if (l === "critical") return "critical";
|
|
47
|
+
if (l === "high") return "high";
|
|
48
|
+
if (l === "moderate" || l === "medium") return "moderate";
|
|
49
|
+
if (l === "low") return "low";
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// pnpm-lock.yaml v9 — a rigid machine-emitted YAML subset; a line scanner is
|
|
55
|
+
// enough and keeps the CLI at one runtime dep (no @pnpm/lockfile-file, whose
|
|
56
|
+
// API churns with pnpm majors). Non-v9 lockfiles degrade honestly:
|
|
57
|
+
// packages-only, no chain/dev classification.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const unquote = (s) => s.replace(/^['"]/, "").replace(/['"]$/, "");
|
|
61
|
+
|
|
62
|
+
// "name@version(peerA@1)(peerB@2)" → { name, version } — peers stripped; the
|
|
63
|
+
// last "@" before the peer suffix splits scoped names correctly.
|
|
64
|
+
export function splitSnapshotKey(key) {
|
|
65
|
+
const base = unquote(key).replace(/\(.*$/, "");
|
|
66
|
+
const at = base.lastIndexOf("@");
|
|
67
|
+
if (at <= 0) return null; // "@scope/…" keeps its leading @; bare "@x" invalid
|
|
68
|
+
const name = base.slice(0, at);
|
|
69
|
+
const version = base.slice(at + 1);
|
|
70
|
+
if (!name || !version) return null;
|
|
71
|
+
return { name, version };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// version values inside snapshot dependency maps carry peer suffixes too:
|
|
75
|
+
// "3.25.2(zod@3.25.76)" → "3.25.2". "link:…" (workspace) resolves to null.
|
|
76
|
+
const cleanVersion = (v) => {
|
|
77
|
+
const s = unquote(String(v).trim()).replace(/\(.*$/, "");
|
|
78
|
+
return s.startsWith("link:") || s === "" ? null : s;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function parsePnpmLock(text) {
|
|
82
|
+
const lines = String(text ?? "").split("\n");
|
|
83
|
+
const versionLine = lines.find((l) => l.startsWith("lockfileVersion:"));
|
|
84
|
+
const version = versionLine ? unquote(versionLine.split(":")[1].trim()) : null;
|
|
85
|
+
|
|
86
|
+
let section = null; // importers | packages | snapshots | other
|
|
87
|
+
let importer = null; // current importer path
|
|
88
|
+
let depKind = null; // dependencies | devDependencies | optionalDependencies
|
|
89
|
+
let importerDep = null; // current dep name inside an importer block
|
|
90
|
+
let snapshot = null; // current snapshot { name, version }
|
|
91
|
+
let snapDepBlock = false;
|
|
92
|
+
|
|
93
|
+
const importers = {}; // path → { prod: [{name,version}], dev: [{name,version}] }
|
|
94
|
+
const edges = new Map(); // "name@version" → [{name,version}]
|
|
95
|
+
const packages = new Map(); // "name@version" → {name,version}
|
|
96
|
+
|
|
97
|
+
for (const raw of lines) {
|
|
98
|
+
if (!raw.trim() || raw.trim().startsWith("#")) continue;
|
|
99
|
+
const indent = raw.length - raw.trimStart().length;
|
|
100
|
+
const line = raw.trim();
|
|
101
|
+
|
|
102
|
+
if (indent === 0) {
|
|
103
|
+
section = line.replace(/:$/, "");
|
|
104
|
+
importer = snapshot = importerDep = depKind = null;
|
|
105
|
+
snapDepBlock = false;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (section === "importers") {
|
|
110
|
+
if (indent === 2 && line.endsWith(":")) {
|
|
111
|
+
importer = unquote(line.slice(0, -1));
|
|
112
|
+
importers[importer] = { prod: [], dev: [] };
|
|
113
|
+
depKind = null;
|
|
114
|
+
} else if (indent === 4 && line.endsWith(":")) {
|
|
115
|
+
const k = line.slice(0, -1);
|
|
116
|
+
depKind = ["dependencies", "devDependencies", "optionalDependencies"].includes(k) ? k : null;
|
|
117
|
+
importerDep = null;
|
|
118
|
+
} else if (indent === 6 && line.endsWith(":") && depKind) {
|
|
119
|
+
importerDep = unquote(line.slice(0, -1));
|
|
120
|
+
} else if (indent === 8 && importerDep && depKind && line.startsWith("version:")) {
|
|
121
|
+
const v = cleanVersion(line.slice("version:".length));
|
|
122
|
+
if (v) {
|
|
123
|
+
const entry = { name: importerDep, version: v };
|
|
124
|
+
(depKind === "devDependencies" ? importers[importer].dev : importers[importer].prod).push(entry);
|
|
125
|
+
packages.set(`${entry.name}@${entry.version}`, entry);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (section === "snapshots") {
|
|
132
|
+
if (indent === 2 && line.endsWith(":")) {
|
|
133
|
+
snapshot = splitSnapshotKey(line.slice(0, -1));
|
|
134
|
+
snapDepBlock = false;
|
|
135
|
+
if (snapshot) {
|
|
136
|
+
const id = `${snapshot.name}@${snapshot.version}`;
|
|
137
|
+
packages.set(id, { name: snapshot.name, version: snapshot.version });
|
|
138
|
+
if (!edges.has(id)) edges.set(id, []);
|
|
139
|
+
}
|
|
140
|
+
} else if (indent === 4 && line.endsWith(":") && snapshot) {
|
|
141
|
+
snapDepBlock = ["dependencies:", "optionalDependencies:", "peerDependencies:"].includes(line)
|
|
142
|
+
? line !== "peerDependencies:"
|
|
143
|
+
: false;
|
|
144
|
+
} else if (indent === 6 && snapshot && snapDepBlock) {
|
|
145
|
+
const at = line.indexOf(":");
|
|
146
|
+
if (at > 0) {
|
|
147
|
+
const name = unquote(line.slice(0, at).trim());
|
|
148
|
+
const v = cleanVersion(line.slice(at + 1));
|
|
149
|
+
if (v) {
|
|
150
|
+
const dep = { name, version: v };
|
|
151
|
+
edges.get(`${snapshot.name}@${snapshot.version}`).push(dep);
|
|
152
|
+
packages.set(`${name}@${v}`, dep);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const v9 = typeof version === "string" && version.startsWith("9");
|
|
161
|
+
return {
|
|
162
|
+
version,
|
|
163
|
+
packages: [...packages.values()],
|
|
164
|
+
importers: v9 ? importers : {},
|
|
165
|
+
edges: v9 ? edges : new Map(),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// BFS over the snapshot graph from the importer roots: shortest chain + the
|
|
170
|
+
// direct flag + dev-only classification (dev=true iff reachable ONLY from
|
|
171
|
+
// devDependency roots — an approximation the P3 rule tolerates: it downgrades,
|
|
172
|
+
// never omits).
|
|
173
|
+
export function classifyReachability(parsed) {
|
|
174
|
+
const info = new Map(); // "name@version" → { direct, dev, path }
|
|
175
|
+
const walk = (roots, dev) => {
|
|
176
|
+
const queue = roots.map((r) => ({ ...r, path: [`${r.name}@${r.version}`] }));
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
while (queue.length) {
|
|
179
|
+
const cur = queue.shift();
|
|
180
|
+
const id = `${cur.name}@${cur.version}`;
|
|
181
|
+
if (seen.has(id)) continue;
|
|
182
|
+
seen.add(id);
|
|
183
|
+
const prev = info.get(id);
|
|
184
|
+
if (!prev) {
|
|
185
|
+
info.set(id, { direct: cur.path.length === 1, dev, path: cur.path });
|
|
186
|
+
} else if (prev.dev && !dev) {
|
|
187
|
+
prev.dev = false; // prod reachability wins
|
|
188
|
+
if (cur.path.length < prev.path.length) prev.path = cur.path;
|
|
189
|
+
if (cur.path.length === 1) prev.direct = true;
|
|
190
|
+
}
|
|
191
|
+
for (const dep of parsed.edges.get(id) ?? []) {
|
|
192
|
+
queue.push({ ...dep, path: [...cur.path, `${dep.name}@${dep.version}`] });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const prodRoots = [];
|
|
197
|
+
const devRoots = [];
|
|
198
|
+
for (const imp of Object.values(parsed.importers)) {
|
|
199
|
+
prodRoots.push(...imp.prod);
|
|
200
|
+
devRoots.push(...imp.dev);
|
|
201
|
+
}
|
|
202
|
+
walk(prodRoots, false);
|
|
203
|
+
walk(devRoots, true);
|
|
204
|
+
return info;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function chainFor(pkg, reachability) {
|
|
208
|
+
const entry = reachability?.get(`${pkg.name}@${pkg.version}`);
|
|
209
|
+
if (!entry) return { direct: false, path: [] };
|
|
210
|
+
return { direct: entry.direct, path: entry.path };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// OSV querybatch bodies, chunked to the API's 1000-query page.
|
|
214
|
+
export function buildOsvQueries(packages, chunkSize = 1000) {
|
|
215
|
+
const queries = packages.map((p) => ({
|
|
216
|
+
package: { name: p.name, ecosystem: "npm" },
|
|
217
|
+
version: p.version,
|
|
218
|
+
}));
|
|
219
|
+
const chunks = [];
|
|
220
|
+
for (let i = 0; i < queries.length; i += chunkSize) chunks.push(queries.slice(i, i + chunkSize));
|
|
221
|
+
return chunks;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const finding = (partial) => ({
|
|
225
|
+
id: null,
|
|
226
|
+
tier: null,
|
|
227
|
+
tool: null,
|
|
228
|
+
severity: "info",
|
|
229
|
+
cvss: null,
|
|
230
|
+
title: null,
|
|
231
|
+
detail: null,
|
|
232
|
+
refs: [],
|
|
233
|
+
vulnId: null,
|
|
234
|
+
aliases: [],
|
|
235
|
+
package: null,
|
|
236
|
+
chain: null,
|
|
237
|
+
fixedIn: null,
|
|
238
|
+
dev: null,
|
|
239
|
+
location: null,
|
|
240
|
+
ruleId: null,
|
|
241
|
+
fingerprint: null,
|
|
242
|
+
...partial,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const cvssScoreOf = (vuln) => {
|
|
246
|
+
for (const s of vuln.severity ?? []) {
|
|
247
|
+
// OSV carries vectors; GHSA records also carry a numeric score on some
|
|
248
|
+
// mirrors. Only a NUMBER is usable — a vector is never "computed" here.
|
|
249
|
+
const n = Number(s.score);
|
|
250
|
+
if (!Number.isNaN(n) && n > 0) return n;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Naive numeric semver compare — enough to rank fixed-version candidates.
|
|
256
|
+
const cmpVersions = (a, b) => {
|
|
257
|
+
const pa = String(a).split(/[.+-]/).map(Number);
|
|
258
|
+
const pb = String(b).split(/[.+-]/).map(Number);
|
|
259
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
260
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
261
|
+
if (!Number.isNaN(d) && d !== 0) return d;
|
|
262
|
+
}
|
|
263
|
+
return 0;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// An advisory often carries one range per affected major (3.x fixed at 3.15.0,
|
|
267
|
+
// 4.x at 4.1.2). Report the HIGHEST fixed version — the branch a current
|
|
268
|
+
// install upgrades toward; the advisory refs carry the per-range detail.
|
|
269
|
+
const fixedInOf = (vuln, pkgName) => {
|
|
270
|
+
let best = null;
|
|
271
|
+
for (const aff of vuln.affected ?? []) {
|
|
272
|
+
if (aff.package?.name !== pkgName) continue;
|
|
273
|
+
for (const range of aff.ranges ?? []) {
|
|
274
|
+
for (const ev of range.events ?? []) {
|
|
275
|
+
if (ev.fixed && (best === null || cmpVersions(ev.fixed, best) > 0)) best = ev.fixed;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return best;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// osvResults: aligned with the query list — [{ vulns: [{id}] } | {}] per
|
|
283
|
+
// package. vulnDetails: Map<id, full /v1/vulns/{id} record> (may be partial —
|
|
284
|
+
// a detail the caller could not fetch yields a shallower finding, never a
|
|
285
|
+
// guessed one).
|
|
286
|
+
export function fromOsvBatch(osvResults, packages, vulnDetails, reachability) {
|
|
287
|
+
const out = [];
|
|
288
|
+
for (let i = 0; i < (osvResults ?? []).length; i++) {
|
|
289
|
+
const pkg = packages[i];
|
|
290
|
+
for (const hit of osvResults[i]?.vulns ?? []) {
|
|
291
|
+
const vuln = vulnDetails?.get?.(hit.id) ?? hit;
|
|
292
|
+
const cvss = cvssScoreOf(vuln);
|
|
293
|
+
const label = normSeverity(vuln.database_specific?.severity);
|
|
294
|
+
const chain = chainFor(pkg, reachability);
|
|
295
|
+
const entry = reachability?.get(`${pkg.name}@${pkg.version}`);
|
|
296
|
+
out.push(
|
|
297
|
+
finding({
|
|
298
|
+
id: `dependency:${hit.id}:${pkg.name}`,
|
|
299
|
+
tier: "dependency",
|
|
300
|
+
tool: "osv-api",
|
|
301
|
+
severity: label ?? severityFromCvss(cvss) ?? "info",
|
|
302
|
+
cvss,
|
|
303
|
+
title: vuln.summary ?? `${hit.id} in ${pkg.name}`,
|
|
304
|
+
detail: vuln.details ? String(vuln.details).slice(0, 2000) : null,
|
|
305
|
+
refs: (vuln.references ?? []).map((r) => r.url).filter(Boolean),
|
|
306
|
+
vulnId: hit.id,
|
|
307
|
+
aliases: vuln.aliases ?? [],
|
|
308
|
+
package: { name: pkg.name, version: pkg.version, ecosystem: "npm" },
|
|
309
|
+
chain,
|
|
310
|
+
fixedIn: fixedInOf(vuln, pkg.name),
|
|
311
|
+
dev: entry ? entry.dev : null,
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// pnpm audit --json → npm-audit-v1 advisories map.
|
|
320
|
+
export function fromPnpmAudit(json, reachability) {
|
|
321
|
+
const out = [];
|
|
322
|
+
for (const adv of Object.values(json?.advisories ?? {})) {
|
|
323
|
+
const name = adv.module_name;
|
|
324
|
+
const ghsa = adv.github_advisory_id ?? (adv.url?.match(/GHSA-[a-z0-9-]+/i)?.[0] ?? null);
|
|
325
|
+
const vulnId = ghsa ?? (adv.cves?.[0] ?? `NPM-${adv.id}`);
|
|
326
|
+
const version = adv.findings?.[0]?.version ?? null;
|
|
327
|
+
const key = version ? `${name}@${version}` : null;
|
|
328
|
+
const entry = key ? reachability?.get(key) : null;
|
|
329
|
+
out.push(
|
|
330
|
+
finding({
|
|
331
|
+
id: `dependency:${vulnId}:${name}`,
|
|
332
|
+
tier: "dependency",
|
|
333
|
+
tool: "pnpm-audit",
|
|
334
|
+
severity: normSeverity(adv.severity) ?? "info",
|
|
335
|
+
cvss: typeof adv.cvss?.score === "number" && adv.cvss.score > 0 ? adv.cvss.score : null,
|
|
336
|
+
title: adv.title ?? `${vulnId} in ${name}`,
|
|
337
|
+
detail: adv.overview ? String(adv.overview).slice(0, 2000) : null,
|
|
338
|
+
refs: [adv.url].filter(Boolean),
|
|
339
|
+
vulnId,
|
|
340
|
+
aliases: adv.cves ?? [],
|
|
341
|
+
package: { name, version, ecosystem: "npm" },
|
|
342
|
+
chain: entry ? { direct: entry.direct, path: entry.path } : null,
|
|
343
|
+
fixedIn: adv.patched_versions && adv.patched_versions !== "<0.0.0" ? adv.patched_versions : null,
|
|
344
|
+
dev: entry ? entry.dev : null,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// gitleaks JSON report rows. NEVER the secret bytes — RuleID + location +
|
|
352
|
+
// gitleaks' own fingerprint only.
|
|
353
|
+
export function fromGitleaks(rows) {
|
|
354
|
+
return (rows ?? []).map((r) =>
|
|
355
|
+
finding({
|
|
356
|
+
id: `secret:${r.RuleID}:${r.File}:${r.StartLine}`,
|
|
357
|
+
tier: "secret",
|
|
358
|
+
tool: "gitleaks",
|
|
359
|
+
severity: "high",
|
|
360
|
+
title: `${r.Description ?? r.RuleID} in ${r.File}`,
|
|
361
|
+
detail: `gitleaks rule ${r.RuleID} matched at ${r.File}:${r.StartLine}`,
|
|
362
|
+
location: { file: r.File, line: r.StartLine ?? null },
|
|
363
|
+
ruleId: r.RuleID,
|
|
364
|
+
fingerprint: r.Fingerprint ?? null,
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Built-in secret matches — the caller runs SECRET_PATTERNS over tracked files
|
|
370
|
+
// and hands rows {rule, severity, file, line, fingerprint} (fingerprint is a
|
|
371
|
+
// hash the caller computed; the matched bytes never enter this module).
|
|
372
|
+
export function fromRegexSecrets(rows) {
|
|
373
|
+
return (rows ?? []).map((r) =>
|
|
374
|
+
finding({
|
|
375
|
+
id: `secret:${r.rule}:${r.file}:${r.line}`,
|
|
376
|
+
tier: "secret",
|
|
377
|
+
tool: "regex-fallback",
|
|
378
|
+
severity: r.severity ?? "high",
|
|
379
|
+
title: `${r.title ?? r.rule} in ${r.file}`,
|
|
380
|
+
detail: `pattern ${r.rule} matched at ${r.file}:${r.line} (redacted)`,
|
|
381
|
+
location: { file: r.file, line: r.line },
|
|
382
|
+
ruleId: r.rule,
|
|
383
|
+
fingerprint: r.fingerprint ?? null,
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// semgrep SARIF (runs[0].results).
|
|
389
|
+
export function fromSemgrepSarif(sarif) {
|
|
390
|
+
const out = [];
|
|
391
|
+
for (const run of sarif?.runs ?? []) {
|
|
392
|
+
for (const res of run.results ?? []) {
|
|
393
|
+
const loc = res.locations?.[0]?.physicalLocation;
|
|
394
|
+
const file = loc?.artifactLocation?.uri ?? "unknown";
|
|
395
|
+
const line = loc?.region?.startLine ?? null;
|
|
396
|
+
const severity =
|
|
397
|
+
res.level === "error" ? "high" : res.level === "warning" ? "moderate" : "low";
|
|
398
|
+
out.push(
|
|
399
|
+
finding({
|
|
400
|
+
id: `sast:${res.ruleId}:${file}:${line}`,
|
|
401
|
+
tier: "sast",
|
|
402
|
+
tool: "semgrep",
|
|
403
|
+
severity,
|
|
404
|
+
title: `${res.ruleId} at ${file}${line ? `:${line}` : ""}`,
|
|
405
|
+
detail: res.message?.text ? String(res.message.text).slice(0, 2000) : null,
|
|
406
|
+
location: { file, line },
|
|
407
|
+
ruleId: res.ruleId,
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Curated built-in secret patterns — high-signal, low-noise. Each match is
|
|
416
|
+
// reported by rule + location + fingerprint; the matched text itself is
|
|
417
|
+
// redacted by the caller before anything leaves the process.
|
|
418
|
+
export const SECRET_PATTERNS = [
|
|
419
|
+
{ rule: "private-key-block", title: "Private key material", severity: "critical", re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY(?: BLOCK)?-----/ },
|
|
420
|
+
{ rule: "aws-access-key-id", title: "AWS access key id", severity: "critical", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
421
|
+
{ rule: "github-token", title: "GitHub token", severity: "critical", re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|\bgithub_pat_[A-Za-z0-9_]{60,255}\b/ },
|
|
422
|
+
{ rule: "stripe-live-key", title: "Stripe live secret key", severity: "critical", re: /\bsk_live_[0-9a-zA-Z]{20,}\b/ },
|
|
423
|
+
{ rule: "slack-token", title: "Slack token", severity: "high", re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/ },
|
|
424
|
+
{ rule: "google-api-key", title: "Google API key", severity: "high", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
425
|
+
{ rule: "npm-token", title: "npm token", severity: "high", re: /\bnpm_[A-Za-z0-9]{36}\b/ },
|
|
426
|
+
{ rule: "anthropic-api-key", title: "Anthropic API key", severity: "critical", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
|
427
|
+
{ rule: "openai-api-key", title: "OpenAI API key", severity: "critical", re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/ },
|
|
428
|
+
{ rule: "generic-assigned-secret", title: "High-entropy assigned secret", severity: "high", entropy: 3.8, re: /(?:secret|token|password|api[_-]?key)\s*[:=]\s*["']([A-Za-z0-9+/_=-]{24,})["']/i },
|
|
429
|
+
];
|
|
430
|
+
|
|
431
|
+
export function entropyOf(s) {
|
|
432
|
+
if (!s) return 0;
|
|
433
|
+
const freq = {};
|
|
434
|
+
for (const c of s) freq[c] = (freq[c] ?? 0) + 1;
|
|
435
|
+
let e = 0;
|
|
436
|
+
for (const n of Object.values(freq)) {
|
|
437
|
+
const p = n / s.length;
|
|
438
|
+
e -= p * Math.log2(p);
|
|
439
|
+
}
|
|
440
|
+
return e;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Merge findings from several sources: keyed by id; on collision keep the
|
|
444
|
+
// richer record and the HIGHER severity (a source that knows more never loses
|
|
445
|
+
// to one that knows less).
|
|
446
|
+
export function dedupeFindings(findings) {
|
|
447
|
+
const byId = new Map();
|
|
448
|
+
for (const f of findings) {
|
|
449
|
+
const prev = byId.get(f.id);
|
|
450
|
+
if (!prev) {
|
|
451
|
+
byId.set(f.id, f);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const higher = SEV_RANK[f.severity] < SEV_RANK[prev.severity] ? f : prev;
|
|
455
|
+
const lower = higher === f ? prev : f;
|
|
456
|
+
byId.set(f.id, {
|
|
457
|
+
...lower,
|
|
458
|
+
...Object.fromEntries(Object.entries(higher).filter(([, v]) => v !== null && v !== undefined)),
|
|
459
|
+
severity: higher.severity,
|
|
460
|
+
refs: [...new Set([...(prev.refs ?? []), ...(f.refs ?? [])])],
|
|
461
|
+
aliases: [...new Set([...(prev.aliases ?? []), ...(f.aliases ?? [])])],
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return [...byId.values()];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function summarize(findings) {
|
|
468
|
+
const summary = { critical: 0, high: 0, moderate: 0, low: 0, info: 0, total: 0 };
|
|
469
|
+
for (const f of findings) {
|
|
470
|
+
summary[f.severity] = (summary[f.severity] ?? 0) + 1;
|
|
471
|
+
summary.total++;
|
|
472
|
+
}
|
|
473
|
+
return summary;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function buildReport({ at, ref, tiers, findings }) {
|
|
477
|
+
const sorted = [...findings].sort(
|
|
478
|
+
(a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || TIER_ORDER[a.tier] - TIER_ORDER[b.tier] || a.id.localeCompare(b.id),
|
|
479
|
+
);
|
|
480
|
+
return {
|
|
481
|
+
schema: AUDIT_SCHEMA,
|
|
482
|
+
at: at ?? null,
|
|
483
|
+
ref: ref ?? null,
|
|
484
|
+
tiers,
|
|
485
|
+
summary: summarize(sorted),
|
|
486
|
+
findings: sorted,
|
|
487
|
+
};
|
|
488
|
+
}
|