@blamejs/exceptd-skills 0.14.10 → 0.14.12
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 +47 -0
- package/bin/exceptd.js +195 -57
- package/data/_indexes/_meta.json +2 -2
- package/lib/citation-resolve.js +4 -1
- package/lib/collectors/cicd-pipeline-compromise.js +8 -2
- package/lib/collectors/citation-hygiene.js +10 -5
- package/lib/collectors/crypto-codebase.js +11 -6
- package/lib/collectors/sbom.js +9 -2
- package/lib/collectors/scan-excludes.js +0 -0
- package/lib/collectors/secrets.js +32 -4
- package/lib/cve-cli.js +12 -4
- package/lib/framework-gap.js +21 -2
- package/lib/playbook-runner.js +41 -20
- package/lib/prefetch.js +35 -1
- package/lib/refresh-external.js +70 -4
- package/lib/refresh-network.js +16 -1
- package/lib/rfc-cli.js +7 -2
- package/lib/schemas/playbook.schema.json +3 -1
- package/lib/scoring.js +8 -1
- package/lib/validate-playbooks.js +119 -0
- package/manifest.json +44 -44
- package/orchestrator/index.js +121 -14
- package/package.json +1 -1
- package/sbom.cdx.json +50 -50
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
const fs = require("node:fs");
|
|
31
31
|
const path = require("node:path");
|
|
32
32
|
|
|
33
|
-
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
33
|
+
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
|
|
34
34
|
|
|
35
35
|
const COLLECTOR_ID = "citation-hygiene";
|
|
36
36
|
|
|
@@ -334,12 +334,15 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
334
334
|
for (const m of content.matchAll(CVE_CITATION_RE)) {
|
|
335
335
|
const full = m[0];
|
|
336
336
|
totalCveCitations++;
|
|
337
|
+
// 1-based line of the citation so the evidence location carries a SARIF
|
|
338
|
+
// startLine region. Does not change any hit/miss verdict.
|
|
339
|
+
const cveLine = lineFromOffset(content, m.index);
|
|
337
340
|
const canonical = CVE_CANONICAL_RE.test(full);
|
|
338
341
|
if (!canonical) {
|
|
339
342
|
// Fabricated / malformed. Illustrative surfaces (templates,
|
|
340
343
|
// fixtures, the format-explaining docs) are demoted.
|
|
341
344
|
if (!illustrative) {
|
|
342
|
-
hits["fabricated-cve-id"].push({ file: f.rel, citation: full });
|
|
345
|
+
hits["fabricated-cve-id"].push({ file: f.rel, citation: full, line: cveLine });
|
|
343
346
|
}
|
|
344
347
|
continue;
|
|
345
348
|
}
|
|
@@ -347,7 +350,7 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
347
350
|
if (cveKeys.has(full)) {
|
|
348
351
|
const note = cveNotes.get(full) || "";
|
|
349
352
|
if (REJECT_DISPUTE_RE.test(note) && !illustrative) {
|
|
350
|
-
hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full });
|
|
353
|
+
hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full, line: cveLine });
|
|
351
354
|
}
|
|
352
355
|
} else if (catalogsLoaded && !illustrative) {
|
|
353
356
|
// Absent from the curated catalog: needs an external lookup.
|
|
@@ -362,6 +365,7 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
362
365
|
const num = Number(m[1]);
|
|
363
366
|
if (!Number.isFinite(num)) continue;
|
|
364
367
|
const line = lineAround(content, m.index);
|
|
368
|
+
const rfcLineNo = lineFromOffset(content, m.index);
|
|
365
369
|
if (rfcTitles.has(num)) {
|
|
366
370
|
const verdict = classifyRfcTitle(line, rfcTitles.get(num));
|
|
367
371
|
if (verdict === "mismatch" && !illustrative) {
|
|
@@ -369,6 +373,7 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
369
373
|
file: f.rel,
|
|
370
374
|
citation: `RFC ${num}`,
|
|
371
375
|
real_title: rfcTitles.get(num),
|
|
376
|
+
line: rfcLineNo,
|
|
372
377
|
});
|
|
373
378
|
}
|
|
374
379
|
} else if (catalogsLoaded && !illustrative) {
|
|
@@ -449,8 +454,8 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
449
454
|
|
|
450
455
|
// Per-indicator file locations for the indicators flipped to "hit",
|
|
451
456
|
// so SARIF results point at the source file that carries the bad
|
|
452
|
-
// citation. The hits record
|
|
453
|
-
//
|
|
457
|
+
// citation. The hits record a 1-based `line` (from the match offset),
|
|
458
|
+
// so locations include a startLine region.
|
|
454
459
|
const evidence_locations = {};
|
|
455
460
|
for (const id of Object.keys(hits)) {
|
|
456
461
|
if (signal_overrides[id] === "hit") {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require("node:fs");
|
|
18
18
|
const path = require("node:path");
|
|
19
|
-
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
19
|
+
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
|
|
20
20
|
|
|
21
21
|
const COLLECTOR_ID = "crypto-codebase";
|
|
22
22
|
|
|
@@ -298,14 +298,17 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
298
298
|
if (RSA_1024_RE.test(content)) {
|
|
299
299
|
hits["rsa-1024-anywhere"].push({ file: f.rel });
|
|
300
300
|
}
|
|
301
|
+
// Attach a 1-based `line` (from the match offset) so the evidence
|
|
302
|
+
// location carries a SARIF startLine region rather than pointing at
|
|
303
|
+
// the file. Does not change hit/miss — the same matches still fire.
|
|
301
304
|
const mrHits = scanMathRandom(content);
|
|
302
|
-
for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset });
|
|
305
|
+
for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset) });
|
|
303
306
|
|
|
304
307
|
const pHits = scanPbkdf2(content);
|
|
305
|
-
for (const h of pHits) hits["pbkdf2-under-iterated"].push({ file: f.rel, iter: h.iter, threshold: h.threshold });
|
|
308
|
+
for (const h of pHits) hits["pbkdf2-under-iterated"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset), iter: h.iter, threshold: h.threshold });
|
|
306
309
|
|
|
307
310
|
const bHits = scanBcrypt(content);
|
|
308
|
-
for (const h of bHits) hits["bcrypt-cost-low"].push({ file: f.rel, cost: h.cost });
|
|
311
|
+
for (const h of bHits) hits["bcrypt-cost-low"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset), cost: h.cost });
|
|
309
312
|
|
|
310
313
|
if (PEM_RE.test(content)) {
|
|
311
314
|
hits["hardcoded-key-material"].push({ file: f.rel });
|
|
@@ -460,8 +463,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
460
463
|
// no-ml-kem-implementation, fips-claim-without-runtime-activation,
|
|
461
464
|
// vendored-pqc-no-provenance) describe a whole-repo state rather than a
|
|
462
465
|
// single offending file, so they carry no file-level location. The
|
|
463
|
-
// call-site scans
|
|
464
|
-
//
|
|
466
|
+
// offset-bearing call-site scans (math-random / pbkdf2 / bcrypt) now record
|
|
467
|
+
// a 1-based `line`, so their locations include a startLine region; the
|
|
468
|
+
// remaining whole-file scans (weak-hash / weak-cipher / rsa-1024 /
|
|
469
|
+
// hardcoded-key / tls) stay file-level (no startLine).
|
|
465
470
|
const evidence_locations = {};
|
|
466
471
|
for (const id of Object.keys(hits)) {
|
|
467
472
|
if (signal_overrides[id] === "hit") {
|
package/lib/collectors/sbom.js
CHANGED
|
@@ -356,8 +356,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
356
356
|
let withoutIntegrity = 0;
|
|
357
357
|
const walk = (obj) => {
|
|
358
358
|
if (!obj || typeof obj !== "object") return;
|
|
359
|
-
|
|
360
|
-
|
|
359
|
+
// Only remote-tarball entries (those with a `resolved` URL) are
|
|
360
|
+
// expected to carry an `integrity` hash. The npm 7+ root entry
|
|
361
|
+
// `"": { name, version }` legitimately has no `resolved` and no
|
|
362
|
+
// `integrity`, so keying off `version` would false-positive on
|
|
363
|
+
// every clean lockfile. Mirror library-author.js's guard.
|
|
364
|
+
if (obj.resolved != null) {
|
|
365
|
+
if (obj.integrity != null) withIntegrity++;
|
|
366
|
+
else withoutIntegrity++;
|
|
367
|
+
}
|
|
361
368
|
for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
|
|
362
369
|
};
|
|
363
370
|
walk(j.packages || j.dependencies || {});
|
|
Binary file
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const fs = require("node:fs");
|
|
20
20
|
const path = require("node:path");
|
|
21
|
-
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
21
|
+
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
|
|
22
22
|
|
|
23
23
|
const COLLECTOR_ID = "secrets";
|
|
24
24
|
|
|
@@ -83,6 +83,18 @@ const IAC_GLOB_PREFIX = ["pulumi.", "arm."];
|
|
|
83
83
|
// source of truth for what counts as a hit; the collector
|
|
84
84
|
// implements the same patterns so its signal_overrides match what
|
|
85
85
|
// the runner would compute.
|
|
86
|
+
// AWS-published documentation/example access-key IDs. These appear verbatim
|
|
87
|
+
// throughout AWS docs, SDK samples, and countless READMEs, so a literal match
|
|
88
|
+
// is example material, not a leaked credential. `cred-stores` demotes the same
|
|
89
|
+
// value (its FP[0]); secrets.js must too or it false-positives on any README
|
|
90
|
+
// that quotes the AWS docs. The 40-char example secret
|
|
91
|
+
// (`wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) carries the literal `EXAMPLE`
|
|
92
|
+
// token, which the AWS-secret-access-key pattern already requires elsewhere;
|
|
93
|
+
// the access-key ID is the one that needs an explicit allowlist.
|
|
94
|
+
const AWS_EXAMPLE_ACCESS_KEY_IDS = new Set([
|
|
95
|
+
"AKIAIOSFODNN7EXAMPLE",
|
|
96
|
+
]);
|
|
97
|
+
|
|
86
98
|
const INDICATOR_PATTERNS = [
|
|
87
99
|
{ id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
88
100
|
{ id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
|
|
@@ -199,10 +211,16 @@ function scanContent(full, rel) {
|
|
|
199
211
|
const matches = buf.matchAll(p.re);
|
|
200
212
|
let count = 0;
|
|
201
213
|
for (const m of matches) {
|
|
214
|
+
// Demote AWS-published example access-key IDs (e.g. the docs' canonical
|
|
215
|
+
// AKIAIOSFODNN7EXAMPLE). A README quoting the AWS docs must not hit.
|
|
216
|
+
if (p.id === "aws-access-key-id" && AWS_EXAMPLE_ACCESS_KEY_IDS.has(m[0])) continue;
|
|
202
217
|
hits.push({
|
|
203
218
|
indicator_id: p.id,
|
|
204
219
|
file: rel,
|
|
205
220
|
offset: m.index,
|
|
221
|
+
// 1-based line of the match so buildEvidenceLocations emits a region
|
|
222
|
+
// (SARIF startLine) instead of a bare file-level location.
|
|
223
|
+
line: lineFromOffset(buf, m.index),
|
|
206
224
|
redacted_match: redactMatch(m[0]),
|
|
207
225
|
});
|
|
208
226
|
if (++count >= 5) break; // cap per-indicator-per-file
|
|
@@ -264,6 +282,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
264
282
|
if (r.hits) allHits.push(...r.hits);
|
|
265
283
|
if (r.skipped === "read_error") {
|
|
266
284
|
errors.push({ artifact_id: "secret-regex-scan-text-files", kind: "read_failed", reason: `${f.rel}: ${r.reason}` });
|
|
285
|
+
} else if (r.skipped === "file_too_large") {
|
|
286
|
+
// A secret in the first bytes of a large file would otherwise be
|
|
287
|
+
// dropped silently. Record the skip so the operator knows this file
|
|
288
|
+
// was NOT scanned (mirrors crypto-codebase's >1 MB read_failed entry).
|
|
289
|
+
errors.push({
|
|
290
|
+
artifact_id: "secret-regex-scan-text-files",
|
|
291
|
+
kind: "file_too_large_skipped",
|
|
292
|
+
reason: `${f.rel}: ${r.bytes} bytes exceeds ${MAX_FILE_BYTES}-byte scan limit; not scanned for secrets`,
|
|
293
|
+
});
|
|
267
294
|
}
|
|
268
295
|
}
|
|
269
296
|
|
|
@@ -311,9 +338,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
311
338
|
|
|
312
339
|
// Per-indicator file locations for every indicator flipped to "hit", so
|
|
313
340
|
// a SARIF result points at the file carrying the secret / bad posture.
|
|
314
|
-
// Content-regex hits
|
|
315
|
-
//
|
|
316
|
-
// posture indicators contribute the carrier file path directly
|
|
341
|
+
// Content-regex hits carry a 1-based `line` (derived from the match offset),
|
|
342
|
+
// so these locations include a startLine region. The file-presence and
|
|
343
|
+
// posture indicators contribute the carrier file path directly (file-level,
|
|
344
|
+
// no line).
|
|
317
345
|
const evidence_locations = {};
|
|
318
346
|
for (const p of INDICATOR_PATTERNS) {
|
|
319
347
|
if (signal_overrides[p.id] === "hit") {
|
package/lib/cve-cli.js
CHANGED
|
@@ -28,7 +28,11 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
28
28
|
process.exitCode = 1;
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
// Trim the positional so a whitespace-only argument (`cve " "`) is
|
|
32
|
+
// treated identically to a missing one (`cve ""`) — a usage error, not a
|
|
33
|
+
// "fabricated" lookup of the literal spaces.
|
|
34
|
+
const rawId = argv.find((a) => !a.startsWith("--"));
|
|
35
|
+
const id = rawId == null ? rawId : rawId.trim();
|
|
32
36
|
const pretty = flags.has("--pretty");
|
|
33
37
|
const json = flags.has("--json") || pretty;
|
|
34
38
|
|
|
@@ -41,7 +45,12 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
const r = await resolveCve(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
|
|
44
|
-
|
|
48
|
+
// A citation that won't stand up exits non-zero so a CI/script gate trips.
|
|
49
|
+
// Derive `ok` from the same set of statuses that drive the exit code — a
|
|
50
|
+
// non-zero exit must carry ok:false, never the inverted ok:true the
|
|
51
|
+
// envelope previously hardcoded.
|
|
52
|
+
const fails = r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn";
|
|
53
|
+
const body = { verb: "cve", ...r, ok: !fails };
|
|
45
54
|
|
|
46
55
|
if (json) {
|
|
47
56
|
process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
|
|
@@ -57,8 +66,7 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
57
66
|
if (r.reason) line += `\n ${r.reason}`;
|
|
58
67
|
process.stdout.write(line + "\n");
|
|
59
68
|
}
|
|
60
|
-
|
|
61
|
-
if (r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn") {
|
|
69
|
+
if (fails) {
|
|
62
70
|
process.exitCode = 2;
|
|
63
71
|
}
|
|
64
72
|
})();
|
package/lib/framework-gap.js
CHANGED
|
@@ -141,7 +141,7 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
|
|
|
141
141
|
* @param {object} cveCatalog - Parsed cve-catalog.json (optional)
|
|
142
142
|
* @returns {{ frameworks: object, universal_gaps: object[], theater_risks: object[] }}
|
|
143
143
|
*/
|
|
144
|
-
function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
|
|
144
|
+
function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, opts = {}) {
|
|
145
145
|
const scenario = threatScenario.toLowerCase();
|
|
146
146
|
|
|
147
147
|
const relevantGaps = Object.entries(controlGaps).filter(([, gap]) => {
|
|
@@ -207,6 +207,25 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
|
|
|
207
207
|
theater_test_present: !!g.theater_test,
|
|
208
208
|
}));
|
|
209
209
|
|
|
210
|
+
// Summary matching count. With `all` frameworks the summary counts every
|
|
211
|
+
// scenario-relevant gap across the whole catalog (relevantGaps). With an
|
|
212
|
+
// explicit framework filter the summary must agree with the per-framework
|
|
213
|
+
// body the operator actually sees — otherwise `framework-gap nist-800-53
|
|
214
|
+
// <cve>` shows e.g. "2 matching control gap(s)" per-framework but "Summary:
|
|
215
|
+
// 8 matching gaps" (every framework's hits, pre-filter). Sum the per-
|
|
216
|
+
// framework gap_count so body + summary agree. De-duplicate by gap key in
|
|
217
|
+
// case a single gap matches multiple requested frameworks.
|
|
218
|
+
let matchingGapCount;
|
|
219
|
+
if (opts.allFrameworks) {
|
|
220
|
+
matchingGapCount = relevantGaps.length;
|
|
221
|
+
} else {
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
for (const id of frameworkIds) {
|
|
224
|
+
for (const g of frameworkResults[id]?.gaps ?? []) seen.add(g.id);
|
|
225
|
+
}
|
|
226
|
+
matchingGapCount = seen.size;
|
|
227
|
+
}
|
|
228
|
+
|
|
210
229
|
return {
|
|
211
230
|
threat_scenario: threatScenario,
|
|
212
231
|
frameworks: frameworkResults,
|
|
@@ -217,7 +236,7 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}) {
|
|
|
217
236
|
})),
|
|
218
237
|
theater_risks: theaterRisks,
|
|
219
238
|
summary: {
|
|
220
|
-
total_gaps:
|
|
239
|
+
total_gaps: matchingGapCount,
|
|
221
240
|
universal_gaps: universalGaps.length,
|
|
222
241
|
theater_risk_controls: theaterRisks.length
|
|
223
242
|
}
|
package/lib/playbook-runner.js
CHANGED
|
@@ -2345,7 +2345,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2345
2345
|
}] : [];
|
|
2346
2346
|
const base = {
|
|
2347
2347
|
scores,
|
|
2348
|
-
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details:
|
|
2348
|
+
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: `Active exploitation confirmed${c.cisa_kev ? ' (CISA KEV)' : ''}.` }] : [],
|
|
2349
2349
|
remediations,
|
|
2350
2350
|
product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
|
|
2351
2351
|
};
|
|
@@ -2473,9 +2473,18 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2473
2473
|
? { tlp: { label: CSAF_TLP_LABEL[runOpts.tlp] }, text: `TLP:${runOpts.tlp}` }
|
|
2474
2474
|
: null;
|
|
2475
2475
|
|
|
2476
|
+
// CSAF 2.0: an advisory with zero vulnerabilities is a csaf_informational_advisory
|
|
2477
|
+
// (Profile 5, which does not require /vulnerabilities) rather than a
|
|
2478
|
+
// csaf_security_advisory (Profile 4, where an empty vulnerabilities array is
|
|
2479
|
+
// semantically wrong and warns under strict profile validators). A clean run
|
|
2480
|
+
// becomes an informational attestation; any firing CVE/indicator keeps the
|
|
2481
|
+
// security-advisory category.
|
|
2482
|
+
const csafCategory = (cveVulns.length + indicatorVulns.length) > 0
|
|
2483
|
+
? 'csaf_security_advisory'
|
|
2484
|
+
: 'csaf_informational_advisory';
|
|
2476
2485
|
return {
|
|
2477
2486
|
document: {
|
|
2478
|
-
category:
|
|
2487
|
+
category: csafCategory,
|
|
2479
2488
|
csaf_version: '2.0',
|
|
2480
2489
|
publisher: publisherBlock,
|
|
2481
2490
|
title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} CVE(s), ${indicatorHits.length} indicator hit(s), ${(analyze.framework_gap_mapping || []).length} framework gap(s))`,
|
|
@@ -2562,20 +2571,29 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2562
2571
|
// every rule definition is unambiguously attributable to one playbook,
|
|
2563
2572
|
// and cross-playbook merges retain all results.
|
|
2564
2573
|
const rulePrefix = `${playbookSlug}/`;
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2574
|
+
// CVE-match results get the coarse playbook-source location fallback
|
|
2575
|
+
// (passing a null indicator skips the per-indicator evidence-locations
|
|
2576
|
+
// branch). Without any `locations`, GitHub Code Scanning silently DROPS
|
|
2577
|
+
// these results — the highest-severity result class would never surface.
|
|
2578
|
+
const cveFallbackLocs = sarifLocationsForIndicator(playbook, null);
|
|
2579
|
+
const cveResults = analyze.matched_cves.map(c => {
|
|
2580
|
+
const result = {
|
|
2581
|
+
ruleId: `${rulePrefix}${c.cve_id}`,
|
|
2582
|
+
level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
|
|
2583
|
+
message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score == null ? 'not assessed' : analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
|
|
2584
|
+
properties: stripNulls({
|
|
2585
|
+
kind: 'cve_match',
|
|
2586
|
+
rwep: c.rwep,
|
|
2587
|
+
cisa_kev: c.cisa_kev,
|
|
2588
|
+
cisa_kev_due_date: c.cisa_kev_due_date ?? null,
|
|
2589
|
+
active_exploitation: c.active_exploitation ?? null,
|
|
2590
|
+
ai_discovered: c.ai_discovered ?? null,
|
|
2591
|
+
blast_radius_score: analyze.blast_radius_score,
|
|
2592
|
+
}),
|
|
2593
|
+
};
|
|
2594
|
+
if (cveFallbackLocs) result.locations = cveFallbackLocs;
|
|
2595
|
+
return result;
|
|
2596
|
+
});
|
|
2579
2597
|
const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
|
|
2580
2598
|
const indicatorResults = indicatorHits.map(i => {
|
|
2581
2599
|
const locs = sarifLocationsForIndicator(playbook, i);
|
|
@@ -2696,7 +2714,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2696
2714
|
vulnerability: { '@id': vulnIdToUrn(c.cve_id), name: c.cve_id },
|
|
2697
2715
|
products: [productEntry],
|
|
2698
2716
|
timestamp: issued,
|
|
2699
|
-
impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5
|
|
2717
|
+
impact_statement: `RWEP ${c.rwep}. ${analyze.blast_radius_score == null ? 'Blast radius not assessed.' : `Blast radius ${analyze.blast_radius_score}/5.`}`,
|
|
2700
2718
|
};
|
|
2701
2719
|
if (c.vex_status === 'fixed') {
|
|
2702
2720
|
stmt.status = 'fixed';
|
|
@@ -2767,11 +2785,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2767
2785
|
vexAuthor = vexOperatorClean;
|
|
2768
2786
|
} else {
|
|
2769
2787
|
vexAuthor = 'urn:exceptd:operator:unknown';
|
|
2788
|
+
// Same shape + singleton dedupe as the CSAF path so a multi-format emit
|
|
2789
|
+
// produces one canonical bundle_publisher_unclaimed entry that machine
|
|
2790
|
+
// consumers can read consistently (reason/remediation, not message).
|
|
2770
2791
|
pushRunError(runOpts._runErrors, {
|
|
2771
2792
|
kind: 'bundle_publisher_unclaimed',
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
});
|
|
2793
|
+
reason: 'OpenVEX author fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Disposition attribution is unclaimed on this VEX document.',
|
|
2794
|
+
remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
|
|
2795
|
+
}, { dedupeKey: () => 'singleton' });
|
|
2775
2796
|
}
|
|
2776
2797
|
return {
|
|
2777
2798
|
'@context': 'https://openvex.dev/ns/v0.2.0',
|
package/lib/prefetch.js
CHANGED
|
@@ -112,7 +112,7 @@ function parseArgs(argv) {
|
|
|
112
112
|
for (let i = 2; i < argv.length; i++) {
|
|
113
113
|
const a = argv[i];
|
|
114
114
|
if (a === "--force") out.force = true;
|
|
115
|
-
else if (a === "--no-network" || a === "--dry-run") out.noNetwork = true;
|
|
115
|
+
else if (a === "--no-network" || a === "--dry-run" || a === "--air-gap") out.noNetwork = true;
|
|
116
116
|
else if (a === "--quiet") out.quiet = true;
|
|
117
117
|
else if (a === "--help" || a === "-h") out.help = true;
|
|
118
118
|
else if (a === "--source") out.source = argv[++i];
|
|
@@ -121,7 +121,17 @@ function parseArgs(argv) {
|
|
|
121
121
|
else if (a.startsWith("--max-age=")) out.maxAgeMs = parseDuration(a.slice("--max-age=".length));
|
|
122
122
|
else if (a === "--cache-dir") out.cacheDir = path.resolve(argv[++i]);
|
|
123
123
|
else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
|
|
124
|
+
// Any remaining --flag is an unrecognized typo. Record it; main() refuses
|
|
125
|
+
// before any network work rather than silently dropping it.
|
|
126
|
+
else if (typeof a === "string" && a.startsWith("--")) {
|
|
127
|
+
const base = a.indexOf("=") === -1 ? a : a.slice(0, a.indexOf("="));
|
|
128
|
+
(out._unknownFlags || (out._unknownFlags = [])).push(base);
|
|
129
|
+
}
|
|
124
130
|
}
|
|
131
|
+
// The global air-gap switch implies a report-only / no-egress run: treat
|
|
132
|
+
// EXCEPTD_AIR_GAP=1 the same as --no-network so prefetch never plans live
|
|
133
|
+
// fetches under air-gap.
|
|
134
|
+
if (process.env.EXCEPTD_AIR_GAP === "1") out.noNetwork = true;
|
|
125
135
|
return out;
|
|
126
136
|
}
|
|
127
137
|
|
|
@@ -646,12 +656,36 @@ function readCached(cacheDir, source, id, opts = {}) {
|
|
|
646
656
|
}
|
|
647
657
|
}
|
|
648
658
|
|
|
659
|
+
// Known --flag base names prefetch accepts. Drives the unknown-flag error
|
|
660
|
+
// message's known list.
|
|
661
|
+
const PREFETCH_KNOWN_FLAGS = Object.freeze([
|
|
662
|
+
"--force", "--no-network", "--dry-run", "--air-gap", "--quiet", "--help", "-h",
|
|
663
|
+
"--source", "--max-age", "--cache-dir",
|
|
664
|
+
]);
|
|
665
|
+
|
|
649
666
|
async function main() {
|
|
650
667
|
const opts = parseArgs(process.argv);
|
|
651
668
|
if (opts.help) {
|
|
652
669
|
printHelp();
|
|
653
670
|
return;
|
|
654
671
|
}
|
|
672
|
+
|
|
673
|
+
// Reject unknown flags BEFORE any network work. A swallowed typo (e.g.
|
|
674
|
+
// `--max-aeg 12h`) previously fell through to a default full-cache fetch.
|
|
675
|
+
// Exit 2 matches prefetch's existing usage-error convention (invalid
|
|
676
|
+
// --source / --max-age also surface as exit 2 via main()'s catch).
|
|
677
|
+
if (Array.isArray(opts._unknownFlags) && opts._unknownFlags.length > 0) {
|
|
678
|
+
const uniq = [...new Set(opts._unknownFlags)];
|
|
679
|
+
process.stderr.write(JSON.stringify({
|
|
680
|
+
ok: false,
|
|
681
|
+
verb: "prefetch",
|
|
682
|
+
error: `prefetch: unknown flag(s): ${uniq.join(", ")}`,
|
|
683
|
+
unknown_flags: uniq,
|
|
684
|
+
known_flags: PREFETCH_KNOWN_FLAGS,
|
|
685
|
+
}) + "\n");
|
|
686
|
+
process.exitCode = 2;
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
655
689
|
// Why process.exitCode and not process.exit():
|
|
656
690
|
// On Windows + Node 25 (libuv), calling process.exit() synchronously
|
|
657
691
|
// while in-flight fetch / AbortController teardown is still mid-close
|
package/lib/refresh-external.js
CHANGED
|
@@ -109,6 +109,22 @@ function parseArgs(argv) {
|
|
|
109
109
|
// older than 7d or one that was prefetched without a signing keypair.
|
|
110
110
|
// EXCEPTD_FORCE_STALE=1 mirrors for non-interactive automation.
|
|
111
111
|
else if (a === "--force-stale") out.forceStale = true;
|
|
112
|
+
// Aliases that bin/exceptd.js may pass through or translate; accept them
|
|
113
|
+
// here so the unknown-flag guard below doesn't false-reject a legitimate
|
|
114
|
+
// operator invocation. (--no-network / --indexes-only / --network /
|
|
115
|
+
// --curate / --prefetch are normally rewritten upstream, but tolerate
|
|
116
|
+
// them when refresh-external is invoked directly.)
|
|
117
|
+
else if (
|
|
118
|
+
a === "--no-network" || a === "--prefetch" || a === "--indexes-only" ||
|
|
119
|
+
a === "--network" || a === "--curate" || a === "--force-stale-acked"
|
|
120
|
+
) { /* accepted, no-op at this layer */ }
|
|
121
|
+
// Any remaining --flag is an unrecognized typo. Record it; refuse after
|
|
122
|
+
// the loop rather than silently dropping it into a default full-refresh
|
|
123
|
+
// (which previously hit the live network on every source).
|
|
124
|
+
else if (typeof a === "string" && a.startsWith("--")) {
|
|
125
|
+
const base = a.indexOf("=") === -1 ? a : a.slice(0, a.indexOf("="));
|
|
126
|
+
(out._unknownFlags || (out._unknownFlags = [])).push(base);
|
|
127
|
+
}
|
|
112
128
|
}
|
|
113
129
|
if (process.env.EXCEPTD_FORCE_STALE === "1") out.forceStale = true;
|
|
114
130
|
// Report-only is intrinsic to the advisory poll regardless of flag order —
|
|
@@ -202,10 +218,11 @@ Outputs:
|
|
|
202
218
|
|
|
203
219
|
Exit codes (refresh's own scheme — distinct from the seven-phase verbs):
|
|
204
220
|
0 applied (or a clean dry-run with no diffs to surface)
|
|
221
|
+
1 apply-mode downstream gate failed (build-indexes, or a per-source error)
|
|
205
222
|
2 error (unknown --source, unreadable fixture, invalid --advisory id, air-gap refusal)
|
|
206
223
|
3 draft produced, editorial review pending (a successful --advisory seed —
|
|
207
224
|
NOT a failure; run --advisory <id> --apply to land it, or curate first)
|
|
208
|
-
4 network/source unreachable
|
|
225
|
+
4 network/source unreachable OR cache precondition refused (unsigned/stale/tampered/unindexed cache)
|
|
209
226
|
Note: exit 3 here means "review needed", which differs from \`exceptd run\`'s
|
|
210
227
|
exit 3 ("ran but no evidence"). Script \`refresh --advisory\` on the body's
|
|
211
228
|
\`ok\` field, not on \`$? == 0\`.
|
|
@@ -1252,8 +1269,18 @@ async function withCatalogLock(catalogPath, mutator) {
|
|
|
1252
1269
|
}
|
|
1253
1270
|
|
|
1254
1271
|
function chosenSources(opts) {
|
|
1255
|
-
|
|
1272
|
+
// Flag-absent (opts.source == null) means "all sources" — the default
|
|
1273
|
+
// refresh behavior. Flag-present-but-empty (`--source ""`, or a value that
|
|
1274
|
+
// trims to nothing like `--source ","`) is an operator error, not a
|
|
1275
|
+
// silent run-everything: refuse and list the valid names so the typo is
|
|
1276
|
+
// visible rather than masquerading as a full refresh.
|
|
1277
|
+
if (opts.source == null) return Object.values(ALL_SOURCES);
|
|
1256
1278
|
const names = opts.source.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1279
|
+
if (names.length === 0) {
|
|
1280
|
+
const err = new Error(`refresh-external: --source requires at least one source name. Valid: ${Object.keys(ALL_SOURCES).join(", ")}`);
|
|
1281
|
+
err._exceptd_unknown_source = true;
|
|
1282
|
+
throw err;
|
|
1283
|
+
}
|
|
1257
1284
|
const out = [];
|
|
1258
1285
|
for (const n of names) {
|
|
1259
1286
|
if (!ALL_SOURCES[n]) {
|
|
@@ -1412,6 +1439,15 @@ async function seedSingleAdvisory(opts) {
|
|
|
1412
1439
|
process.exitCode = 3;
|
|
1413
1440
|
}
|
|
1414
1441
|
|
|
1442
|
+
// Known --flag base names refresh accepts (operator-facing surface + the
|
|
1443
|
+
// bin-translated aliases). Drives the unknown-flag error message's known list.
|
|
1444
|
+
const REFRESH_KNOWN_FLAGS = Object.freeze([
|
|
1445
|
+
"--apply", "--quiet", "--swarm", "--json", "--help", "-h", "--advisory",
|
|
1446
|
+
"--check-advisories", "--catalog", "--from-cache", "--source", "--from-fixture",
|
|
1447
|
+
"--report-out", "--air-gap", "--force-stale", "--force-stale-acked",
|
|
1448
|
+
"--no-network", "--prefetch", "--indexes-only", "--network", "--curate",
|
|
1449
|
+
]);
|
|
1450
|
+
|
|
1415
1451
|
async function main() {
|
|
1416
1452
|
const opts = parseArgs(process.argv);
|
|
1417
1453
|
if (opts.help) {
|
|
@@ -1421,6 +1457,22 @@ async function main() {
|
|
|
1421
1457
|
return;
|
|
1422
1458
|
}
|
|
1423
1459
|
|
|
1460
|
+
// Reject unknown flags BEFORE any network / catalog work. A swallowed typo
|
|
1461
|
+
// (e.g. `--aply`) previously fell through to a default all-sources live
|
|
1462
|
+
// refresh. Exit 2 matches refresh's own scheme (2 = error / unknown source).
|
|
1463
|
+
if (Array.isArray(opts._unknownFlags) && opts._unknownFlags.length > 0) {
|
|
1464
|
+
const uniq = [...new Set(opts._unknownFlags)];
|
|
1465
|
+
process.stderr.write(JSON.stringify({
|
|
1466
|
+
ok: false,
|
|
1467
|
+
verb: "refresh",
|
|
1468
|
+
error: `refresh: unknown flag(s): ${uniq.join(", ")}`,
|
|
1469
|
+
unknown_flags: uniq,
|
|
1470
|
+
known_flags: REFRESH_KNOWN_FLAGS,
|
|
1471
|
+
}) + "\n");
|
|
1472
|
+
process.exitCode = 2;
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1424
1476
|
// v0.12.0: `--advisory <id>` short-circuits the normal source loop and
|
|
1425
1477
|
// seeds a single CVE catalog entry from GHSA. Exits non-zero ("draft
|
|
1426
1478
|
// written, please review") so CI pipelines surface the needed editorial
|
|
@@ -1428,7 +1480,7 @@ async function main() {
|
|
|
1428
1480
|
// the seed is printed to stdout for review.
|
|
1429
1481
|
// An empty --advisory value (`--advisory ""` / `--advisory=`) must error
|
|
1430
1482
|
// rather than silently falling through to a full-refresh dry-run.
|
|
1431
|
-
if (opts.advisory === "") {
|
|
1483
|
+
if (opts.advisory != null && opts.advisory.trim() === "") {
|
|
1432
1484
|
process.stderr.write(JSON.stringify({
|
|
1433
1485
|
ok: false,
|
|
1434
1486
|
error: "refresh: --advisory requires a non-empty identifier (e.g. CVE-2026-1234, GHSA-xxxx-xxxx-xxxx, MAL-2026-1).",
|
|
@@ -1496,11 +1548,22 @@ async function main() {
|
|
|
1496
1548
|
? await Promise.all(sources.map(runOne))
|
|
1497
1549
|
: await sequential(sources, runOne);
|
|
1498
1550
|
|
|
1551
|
+
// Cache-integrity refusals (sha256 mismatch, missing/partial _index.json,
|
|
1552
|
+
// unindexed payload) are thrown by readCachedJson with _exceptd_exit_code=4
|
|
1553
|
+
// but caught inside runOne and returned as a per-source error — so the
|
|
1554
|
+
// throw never reaches main().catch where the code is otherwise honored.
|
|
1555
|
+
// Carry the marker through here so main() can prefer exit 4 (BLOCKED /
|
|
1556
|
+
// precondition refusal) over the generic per-source-failure exit 1.
|
|
1557
|
+
let cacheIntegrityFailure = false;
|
|
1499
1558
|
for (const { src, diff, error } of outcomes) {
|
|
1500
1559
|
if (error) {
|
|
1501
1560
|
log(`\n [${src.name}] ${src.description}`);
|
|
1502
1561
|
log(` error: ${error.message}`);
|
|
1503
1562
|
report.sources[src.name] = { status: "error", error: error.message };
|
|
1563
|
+
if (error._exceptd_cache_integrity || error._exceptd_exit_code === 4) {
|
|
1564
|
+
report.sources[src.name].cache_integrity = true;
|
|
1565
|
+
cacheIntegrityFailure = true;
|
|
1566
|
+
}
|
|
1504
1567
|
hadFailure = true;
|
|
1505
1568
|
continue;
|
|
1506
1569
|
}
|
|
@@ -1550,7 +1613,10 @@ async function main() {
|
|
|
1550
1613
|
// truncate buffered stdout (refresh-report path log line, summary log
|
|
1551
1614
|
// lines piped to a consumer). exitCode + return lets the event loop end
|
|
1552
1615
|
// naturally and stdout drains in full.
|
|
1553
|
-
|
|
1616
|
+
// Prefer the documented BLOCKED (4) code when any source refused on a
|
|
1617
|
+
// cache-integrity precondition; fall back to generic failure (1) for other
|
|
1618
|
+
// per-source errors / downstream gate failures.
|
|
1619
|
+
process.exitCode = cacheIntegrityFailure ? 4 : (hadFailure ? 1 : 0);
|
|
1554
1620
|
}
|
|
1555
1621
|
|
|
1556
1622
|
async function sequential(items, fn) {
|
package/lib/refresh-network.js
CHANGED
|
@@ -45,14 +45,16 @@ const PKG_NAME = "@blamejs/exceptd-skills";
|
|
|
45
45
|
const REQUEST_TIMEOUT_MS = 15000;
|
|
46
46
|
|
|
47
47
|
function parseArgs(argv) {
|
|
48
|
-
const out = { force: false, dryRun: false, timeoutMs: REQUEST_TIMEOUT_MS, json: false };
|
|
48
|
+
const out = { force: false, dryRun: false, timeoutMs: REQUEST_TIMEOUT_MS, json: false, airGap: false };
|
|
49
49
|
for (let i = 2; i < argv.length; i++) {
|
|
50
50
|
const a = argv[i];
|
|
51
51
|
if (a === "--force") out.force = true;
|
|
52
52
|
else if (a === "--dry-run") out.dryRun = true;
|
|
53
53
|
else if (a === "--json") out.json = true;
|
|
54
|
+
else if (a === "--air-gap") out.airGap = true;
|
|
54
55
|
else if (a === "--timeout") out.timeoutMs = parseInt(argv[++i], 10) || REQUEST_TIMEOUT_MS;
|
|
55
56
|
}
|
|
57
|
+
if (process.env.EXCEPTD_AIR_GAP === "1") out.airGap = true;
|
|
56
58
|
return out;
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -275,6 +277,19 @@ async function main() {
|
|
|
275
277
|
const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
276
278
|
const localVersion = localPkg.version;
|
|
277
279
|
|
|
280
|
+
// Air-gap refusal. --network needs egress to registry.npmjs.org for the
|
|
281
|
+
// /latest metadata + tarball pull. Under air-gap there is no offline
|
|
282
|
+
// substitute (the test fixture path remains available for offline tests),
|
|
283
|
+
// so refuse before any network attempt and point at the offline workflow.
|
|
284
|
+
if (opts.airGap && !process.env.EXCEPTD_REGISTRY_FIXTURE) {
|
|
285
|
+
emit({
|
|
286
|
+
ok: false,
|
|
287
|
+
source: "air-gap",
|
|
288
|
+
error: "air-gap: refresh --network requires network egress; refused. Use --from-cache --apply for the offline path.",
|
|
289
|
+
}, opts.json);
|
|
290
|
+
process.exitCode = 4; return;
|
|
291
|
+
}
|
|
292
|
+
|
|
278
293
|
progress(`local v${localVersion} — querying npm registry...`, opts.json);
|
|
279
294
|
|
|
280
295
|
let meta;
|
package/lib/rfc-cli.js
CHANGED
|
@@ -57,7 +57,12 @@ const { resolveRfc } = require("./citation-resolve.js");
|
|
|
57
57
|
const a = norm(claimedTitle), b = norm(r.title);
|
|
58
58
|
titleMatch = a.length > 0 && (b.includes(a) || a.includes(b));
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
// Derive `ok` from the resolved status + title-check the same way the exit
|
|
61
|
+
// code is derived below — a non-zero exit (status nonexistent OR an explicit
|
|
62
|
+
// title mismatch) must carry ok:false, not the inverted ok:true the envelope
|
|
63
|
+
// previously hardcoded.
|
|
64
|
+
const fails = r.status === "nonexistent" || titleMatch === false;
|
|
65
|
+
const body = { verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}), ok: !fails };
|
|
61
66
|
|
|
62
67
|
if (json) {
|
|
63
68
|
process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
|
|
@@ -77,5 +82,5 @@ const { resolveRfc } = require("./citation-resolve.js");
|
|
|
77
82
|
process.stdout.write(line + "\n");
|
|
78
83
|
}
|
|
79
84
|
// A mismatched or nonexistent citation is a non-zero exit for gates.
|
|
80
|
-
if (
|
|
85
|
+
if (fails) process.exitCode = 2;
|
|
81
86
|
})();
|