@blamejs/exceptd-skills 0.14.11 → 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 +27 -0
- package/bin/exceptd.js +83 -15
- package/data/_indexes/_meta.json +2 -2
- package/lib/cve-cli.js +7 -3
- package/lib/playbook-runner.js +41 -20
- package/lib/prefetch.js +30 -0
- package/lib/refresh-external.js +41 -0
- 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 +98 -11
- package/package.json +1 -1
- package/sbom.cdx.json +32 -32
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.12 — 2026-05-27
|
|
4
|
+
|
|
5
|
+
Structured-bundle accuracy:
|
|
6
|
+
- CSAF advisories no longer attribute exploitation to the CISA KEV catalog for a CVE that is confirmed-exploited but not actually in KEV — the "(CISA KEV)" parenthetical is now conditional on the CVE's KEV status.
|
|
7
|
+
- An empty-evidence run emits a `csaf_informational_advisory` instead of a `csaf_security_advisory` with an empty `vulnerabilities` array (Profile 4 expects vulnerabilities; the informational profile does not).
|
|
8
|
+
- SARIF `cve_match` results now carry a `locations` entry. Without it, GitHub Code Scanning silently dropped the highest-severity result class.
|
|
9
|
+
- SARIF and OpenVEX render "not assessed" for an unassessed blast radius instead of the literal "null" / "null/5".
|
|
10
|
+
- `ci --format csaf|sarif|openvex` emits a JSON array of the pure documents instead of an exceptd wrapper carrying a top-level `ok` key (which is invalid in all three formats). Each array element is now a conformant document.
|
|
11
|
+
|
|
12
|
+
External-source command hardening:
|
|
13
|
+
- `validate-rfcs` / `validate-cves` reject an unknown flag before doing any work, instead of silently defaulting to a live-network run that hangs on a typo'd flag.
|
|
14
|
+
- `cve` and `rfc` now return `ok:false` (not `ok:true`) when the citation fails to stand up — the envelope matched the exit code was already 2, but `ok` was inverted.
|
|
15
|
+
- `refresh`, `prefetch`, and the `scan`/`dispatch`/`currency`/`watchlist` verbs reject unknown flags instead of silently ignoring them; the latter four also emit a top-level `ok` in their `--json` output.
|
|
16
|
+
- `framework-gap` and `skill` honor `--json` on their missing-argument paths (structured error, not plain text), and `skill --json` no longer treats `--json` as the skill name.
|
|
17
|
+
|
|
18
|
+
`doctor`:
|
|
19
|
+
- `doctor --rfcs` counts the whole RFC catalog (including the CSAF/draft/ISO citation families it previously dropped) with a `by_prefix` breakdown, and its freshness fields read the real catalog file instead of a path that never existed.
|
|
20
|
+
- `doctor --fix` re-verifies signatures after generating a key and signing, so a successful bootstrap reports success (exit 0) rather than carrying the pre-fix "signatures failed" state through to a non-zero exit. It also refuses to generate a key when a fingerprint pin is present without the public key (a corrupted checkout) rather than producing an install that can never verify.
|
|
21
|
+
- `doctor --shipped-tarball` runs the tarball round-trip even when combined with another selective flag (it was silently skipped). `doctor --ai-config` reports a warning when its scan hits the file cap, rather than an unqualified clean pass on an incomplete walk.
|
|
22
|
+
|
|
23
|
+
Playbook validation hardening (enforcement for future drift; the shipped corpus is unaffected):
|
|
24
|
+
- `domain.attack_refs` are cross-referenced against the ATT&CK catalog (they were unchecked).
|
|
25
|
+
- An air-gap playbook with a network-sourced artifact lacking an `air_gap_alternative` is now rejected (the schema's air-gap conditional was never executed by the validator).
|
|
26
|
+
- Empty `detect.indicators` / `look.artifacts` are rejected; every playbook must map to at least one real TTP (cross-cutting analysis playbooks excepted). Dangling `false_positive_profile` indicator references and invalid `clock_starts` / `frameworks_in_scope` values now fail validation instead of passing as warnings.
|
|
27
|
+
|
|
28
|
+
RWEP factor validation accepts a numeric string consistently with the scorer (the two surfaces previously disagreed).
|
|
29
|
+
|
|
3
30
|
## 0.14.11 — 2026-05-27
|
|
4
31
|
|
|
5
32
|
Security: `reattest <session-id>` now validates the session-id before it is joined into a filesystem path, the same gate the other read verbs use. A `../`-bearing id previously escaped the attestation root — reading a forged attestation and writing a signed replay record outside the root. Such an id is now refused (exit 1) and nothing is written.
|
package/bin/exceptd.js
CHANGED
|
@@ -6469,7 +6469,11 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
6469
6469
|
const onlyAiConfig = !!args["ai-config"];
|
|
6470
6470
|
const onlyCollectors = !!args.collectors;
|
|
6471
6471
|
const anySelected = onlySigs || onlyCurrency || onlyCves || onlyRfcs || onlyAiConfig || onlyCollectors;
|
|
6472
|
-
|
|
6472
|
+
// --shipped-tarball lives inside the signatures check, so it must imply it.
|
|
6473
|
+
// Pre-fix, `doctor --shipped-tarball --cves` made runSigs false (a selective
|
|
6474
|
+
// flag was set, but not --signatures), silently skipping the tarball
|
|
6475
|
+
// round-trip while the operator believed it ran.
|
|
6476
|
+
const runSigs = !anySelected || onlySigs || !!args["shipped-tarball"];
|
|
6473
6477
|
const runCurrency = !anySelected || onlyCurrency;
|
|
6474
6478
|
const runCves = !anySelected || onlyCves;
|
|
6475
6479
|
const runRfcs = !anySelected || onlyRfcs;
|
|
@@ -6670,23 +6674,34 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
6670
6674
|
timeout: 30000,
|
|
6671
6675
|
});
|
|
6672
6676
|
const text = (res.stdout || "") + (res.stderr || "");
|
|
6673
|
-
const rfcRows = (text.match(/^RFC-\d+/gm) || []).length;
|
|
6674
6677
|
const driftMatch = text.match(/drift[:\s]+(\d+)/i);
|
|
6675
6678
|
const ok = res.status === 0;
|
|
6676
|
-
//
|
|
6677
|
-
//
|
|
6678
|
-
//
|
|
6679
|
+
// Count the catalog directly (same approach the CVE subcheck uses) rather
|
|
6680
|
+
// than scraping `^RFC-\d+` table rows from the validate-rfcs output. The
|
|
6681
|
+
// text scrape dropped every non-RFC family (CSAF / DRAFT / ISO entries),
|
|
6682
|
+
// undercounting the catalog and hiding those citation families. Read the
|
|
6683
|
+
// canonical file and emit a by_prefix breakdown.
|
|
6684
|
+
const rfcCatalogPath = path.join(PKG_ROOT, "data", "rfc-references.json");
|
|
6685
|
+
let rfcTotal = 0;
|
|
6686
|
+
const byPrefix = {};
|
|
6679
6687
|
let rfcMtime = null;
|
|
6680
6688
|
let rfcAgeDays = null;
|
|
6681
6689
|
try {
|
|
6682
|
-
const
|
|
6683
|
-
const
|
|
6690
|
+
const catalog = JSON.parse(fs.readFileSync(rfcCatalogPath, "utf8"));
|
|
6691
|
+
for (const k of Object.keys(catalog)) {
|
|
6692
|
+
if (k.startsWith("_")) continue;
|
|
6693
|
+
rfcTotal++;
|
|
6694
|
+
const prefix = (k.match(/^[A-Za-z]+/) || ["?"])[0].toUpperCase();
|
|
6695
|
+
byPrefix[prefix] = (byPrefix[prefix] || 0) + 1;
|
|
6696
|
+
}
|
|
6697
|
+
const st = fs.statSync(rfcCatalogPath);
|
|
6684
6698
|
rfcMtime = st.mtime.toISOString();
|
|
6685
6699
|
rfcAgeDays = Math.floor((Date.now() - st.mtimeMs) / 86400000);
|
|
6686
|
-
} catch { /* file may
|
|
6700
|
+
} catch { /* file may be absent on exotic installs — total stays 0 */ }
|
|
6687
6701
|
checks.rfcs = {
|
|
6688
6702
|
ok,
|
|
6689
|
-
total:
|
|
6703
|
+
total: rfcTotal,
|
|
6704
|
+
by_prefix: byPrefix,
|
|
6690
6705
|
drift: driftMatch ? Number(driftMatch[1]) : 0,
|
|
6691
6706
|
index_last_modified: rfcMtime,
|
|
6692
6707
|
index_age_days: rfcAgeDays,
|
|
@@ -6968,9 +6983,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
6968
6983
|
}
|
|
6969
6984
|
}
|
|
6970
6985
|
|
|
6986
|
+
// A truncated walk (hit the file/depth cap) means the audit is INCOMPLETE —
|
|
6987
|
+
// a sensitive file beyond the cap would be unseen. Don't report an
|
|
6988
|
+
// unqualified clean pass: downgrade to a warn so automation can branch on
|
|
6989
|
+
// incompleteness even when zero findings surfaced within the cap.
|
|
6990
|
+
const baseSeverity = errorFindings.length > 0 && fixesFailed > 0 ? 'warn' : (errorFindings.length > 0 && !args.fix ? 'warn' : 'info');
|
|
6971
6991
|
checks.ai_config = {
|
|
6972
|
-
ok: errorFindings.length === 0 || (args.fix && fixesFailed === 0),
|
|
6973
|
-
severity:
|
|
6992
|
+
ok: (errorFindings.length === 0 || (args.fix && fixesFailed === 0)) && !walkAborted,
|
|
6993
|
+
severity: walkAborted && baseSeverity === 'info' ? 'warn' : baseSeverity,
|
|
6974
6994
|
scanned_dirs: scannedDirs,
|
|
6975
6995
|
scanned_files: scannedFiles,
|
|
6976
6996
|
walk_truncated: walkAborted,
|
|
@@ -7089,8 +7109,8 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
7089
7109
|
// global `npm install -g` reported `failed_checks: ["signing"]` with
|
|
7090
7110
|
// `warnings_count: 0`, contradicting the [!! warn] text-mode icon.
|
|
7091
7111
|
const { bucketChecks } = require(path.join(PKG_ROOT, "lib", "doctor-bucketing.js"));
|
|
7092
|
-
|
|
7093
|
-
|
|
7112
|
+
let { warnList, errorList } = bucketChecks(checks);
|
|
7113
|
+
let allGreen = errorList.length === 0 && warnList.length === 0;
|
|
7094
7114
|
// Audit 3 B.11: surface the local version on the default doctor output
|
|
7095
7115
|
// so operators answer both "is my install healthy?" AND "which version
|
|
7096
7116
|
// am I running?" without having to invoke `exceptd version` separately.
|
|
@@ -7127,10 +7147,21 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
7127
7147
|
// `exceptd doctor` (signatures check) reports 0/N passing.
|
|
7128
7148
|
if (args.fix && checks.signing && !checks.signing.private_key_present) {
|
|
7129
7149
|
const pubKeyExists = fs.existsSync(path.join(PKG_ROOT, "keys", "public.pem"));
|
|
7150
|
+
const fingerprintPinExists = fs.existsSync(path.join(PKG_ROOT, "keys", "EXPECTED_FINGERPRINT"));
|
|
7130
7151
|
if (pubKeyExists) {
|
|
7131
7152
|
out.summary.fix_attempted = "ed25519_keypair_generation_declined";
|
|
7132
7153
|
out.summary.fix_decline_reason = "keys/public.pem already exists but no matching private key. Generating a fresh keypair would overwrite the public key and orphan every shipped signature. If you intend to establish a new signing identity, run `node $(exceptd path)/lib/sign.js generate-keypair --rotate` followed by sign-all.";
|
|
7133
7154
|
process.stderr.write("[doctor --fix] refused: keys/public.pem present without matching private key. Pass --rotate via the underlying lib/sign.js if a new identity is intended.\n");
|
|
7155
|
+
} else if (fingerprintPinExists) {
|
|
7156
|
+
// A committed EXPECTED_FINGERPRINT without keys/public.pem signals an
|
|
7157
|
+
// intended committed signing identity on a corrupted/partial checkout.
|
|
7158
|
+
// Generating a fresh keypair here would write a public.pem whose
|
|
7159
|
+
// fingerprint can never match the pin, leaving verify.js permanently
|
|
7160
|
+
// refusing (fingerprint-mismatch) while --fix claimed success. Decline
|
|
7161
|
+
// and tell the operator to restore the real public key.
|
|
7162
|
+
out.summary.fix_attempted = "ed25519_keypair_generation_declined";
|
|
7163
|
+
out.summary.fix_decline_reason = "keys/EXPECTED_FINGERPRINT is present but keys/public.pem is missing — this is a corrupted checkout of a project with a committed signing identity, not a fresh contributor checkout. Generating a keypair would produce a public key whose fingerprint cannot match the pin, so verify would refuse forever. Restore keys/public.pem from version control instead (git checkout -- keys/public.pem).";
|
|
7164
|
+
process.stderr.write("[doctor --fix] refused: keys/EXPECTED_FINGERPRINT present without keys/public.pem. Restore the committed public key (git checkout -- keys/public.pem) rather than generating a new identity.\n");
|
|
7134
7165
|
} else {
|
|
7135
7166
|
process.stderr.write("[doctor --fix] generating Ed25519 keypair...\n");
|
|
7136
7167
|
const r = require("child_process").spawnSync(process.execPath, [path.join(PKG_ROOT, "lib", "sign.js"), "generate-keypair"], {
|
|
@@ -7188,6 +7219,37 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
7188
7219
|
}
|
|
7189
7220
|
}
|
|
7190
7221
|
|
|
7222
|
+
// After a --fix that re-signed skills (keypair generation OR re-sign), the
|
|
7223
|
+
// captured `checks.signatures` is STALE — it was the verify.js result taken
|
|
7224
|
+
// before any key existed. Re-verify now and recompute the buckets, so a
|
|
7225
|
+
// successful --fix reports success (and exits 0) instead of carrying the
|
|
7226
|
+
// pre-fix "signatures FAILED" through to failed_checks + a non-zero exit.
|
|
7227
|
+
if (args.fix
|
|
7228
|
+
&& (out.summary.fix_applied === "ed25519_keypair_generated_and_skills_signed"
|
|
7229
|
+
|| out.summary.fix_applied === "skills_resigned_against_current_keypair")) {
|
|
7230
|
+
try {
|
|
7231
|
+
const verifyPath = path.join(PKG_ROOT, "lib", "verify.js");
|
|
7232
|
+
const rv = spawnSync(process.execPath, [verifyPath], { encoding: "utf8", cwd: PKG_ROOT, timeout: 30000 });
|
|
7233
|
+
const rvText = (rv.stdout || "") + (rv.stderr || "");
|
|
7234
|
+
const rvMatch = rvText.match(/(\d+)\/(\d+)\s+skills?\s+passed/i);
|
|
7235
|
+
const rvFp = rvText.match(/SHA256:\s*([A-Za-z0-9+/=]+)/);
|
|
7236
|
+
const rvOk = rv.status === 0;
|
|
7237
|
+
checks.signatures = {
|
|
7238
|
+
ok: rvOk,
|
|
7239
|
+
skills_passed: rvMatch ? Number(rvMatch[1]) : null,
|
|
7240
|
+
skills_total: rvMatch ? Number(rvMatch[2]) : null,
|
|
7241
|
+
fingerprint_sha256: rvFp ? rvFp[1] : null,
|
|
7242
|
+
...(rvOk ? {} : { exit_code: rv.status, raw: rvText.slice(0, 500) }),
|
|
7243
|
+
};
|
|
7244
|
+
out.checks = checks;
|
|
7245
|
+
({ warnList, errorList } = bucketChecks(checks));
|
|
7246
|
+
allGreen = errorList.length === 0 && warnList.length === 0;
|
|
7247
|
+
out.summary.failed_checks = errorList;
|
|
7248
|
+
out.summary.warning_checks = warnList;
|
|
7249
|
+
out.summary.all_green = allGreen;
|
|
7250
|
+
} catch { /* re-verify best-effort; leave the pre-fix state if it throws */ }
|
|
7251
|
+
}
|
|
7252
|
+
|
|
7191
7253
|
// Audit 3 B.3: --fix was passed but nothing to fix. Pre-fix this was
|
|
7192
7254
|
// silently a no-op — operators couldn't distinguish "we tried and were
|
|
7193
7255
|
// already healthy" from "we tried and failed silently." Now surfaces a
|
|
@@ -8543,9 +8605,15 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8543
8605
|
}
|
|
8544
8606
|
process.stdout.write(lines.join("\n") + "\n");
|
|
8545
8607
|
} else if (fmt === "csaf" || fmt === "sarif" || fmt === "openvex") {
|
|
8546
|
-
// Aggregate the per-run bundles_by_format if present.
|
|
8608
|
+
// Aggregate the per-run bundles_by_format if present. ci spans N playbooks,
|
|
8609
|
+
// so there is no single conformant CSAF/SARIF/OpenVEX document — emit a JSON
|
|
8610
|
+
// ARRAY of the pure documents. Critically, do NOT wrap them in an exceptd
|
|
8611
|
+
// envelope carrying a top-level `ok` key: that key is invalid in all three
|
|
8612
|
+
// standard formats, so a downstream CSAF/SARIF/OpenVEX consumer pointed at
|
|
8613
|
+
// `ci --format` output got a non-conformant top-level shape. Each array
|
|
8614
|
+
// element is now a verbatim, conformant document.
|
|
8547
8615
|
const bundles = results.map(r => r.phases?.close?.evidence_package?.bundles_by_format?.[fmt === "csaf" ? "csaf-2.0" : fmt]).filter(Boolean);
|
|
8548
|
-
|
|
8616
|
+
process.stdout.write(JSON.stringify(bundles, null, pretty ? 2 : 0) + "\n");
|
|
8549
8617
|
} else if (fmt && fmt !== "json") {
|
|
8550
8618
|
// v0.11.4 (#76): garbage format rejected with structured error, not silent empty stdout.
|
|
8551
8619
|
// Route through emitError so the body propagates exit codes via the
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-05-
|
|
3
|
+
"generated_at": "2026-05-27T21:30:21.745Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 54,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "9dfc540e93aa24c5c61de5a568ac4ed94a21c04cddea458bb647c17ee3a4ec48",
|
|
8
8
|
"data/atlas-ttps.json": "d24bc02859d40ccf1615db75cca68c077585904e41e0d8f6de448121e9b1abb0",
|
|
9
9
|
"data/attack-techniques.json": "fa193f0d2d248176a8beddb641e9fe56ba4faa9e15dc253ff876dbf0c5d58a77",
|
|
10
10
|
"data/cve-catalog.json": "3d451dda7ac0c7d57a4075ae4bafd3148c6184b35dc1bc59d8b81d1f2641e430",
|
package/lib/cve-cli.js
CHANGED
|
@@ -45,7 +45,12 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const r = await resolveCve(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
|
|
48
|
-
|
|
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 };
|
|
49
54
|
|
|
50
55
|
if (json) {
|
|
51
56
|
process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
|
|
@@ -61,8 +66,7 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
61
66
|
if (r.reason) line += `\n ${r.reason}`;
|
|
62
67
|
process.stdout.write(line + "\n");
|
|
63
68
|
}
|
|
64
|
-
|
|
65
|
-
if (r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn") {
|
|
69
|
+
if (fails) {
|
|
66
70
|
process.exitCode = 2;
|
|
67
71
|
}
|
|
68
72
|
})();
|
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
|
@@ -121,6 +121,12 @@ 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
|
}
|
|
125
131
|
// The global air-gap switch implies a report-only / no-egress run: treat
|
|
126
132
|
// EXCEPTD_AIR_GAP=1 the same as --no-network so prefetch never plans live
|
|
@@ -650,12 +656,36 @@ function readCached(cacheDir, source, id, opts = {}) {
|
|
|
650
656
|
}
|
|
651
657
|
}
|
|
652
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
|
+
|
|
653
666
|
async function main() {
|
|
654
667
|
const opts = parseArgs(process.argv);
|
|
655
668
|
if (opts.help) {
|
|
656
669
|
printHelp();
|
|
657
670
|
return;
|
|
658
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
|
+
}
|
|
659
689
|
// Why process.exitCode and not process.exit():
|
|
660
690
|
// On Windows + Node 25 (libuv), calling process.exit() synchronously
|
|
661
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 —
|
|
@@ -1423,6 +1439,15 @@ async function seedSingleAdvisory(opts) {
|
|
|
1423
1439
|
process.exitCode = 3;
|
|
1424
1440
|
}
|
|
1425
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
|
+
|
|
1426
1451
|
async function main() {
|
|
1427
1452
|
const opts = parseArgs(process.argv);
|
|
1428
1453
|
if (opts.help) {
|
|
@@ -1432,6 +1457,22 @@ async function main() {
|
|
|
1432
1457
|
return;
|
|
1433
1458
|
}
|
|
1434
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
|
+
|
|
1435
1476
|
// v0.12.0: `--advisory <id>` short-circuits the normal source loop and
|
|
1436
1477
|
// seeds a single CVE catalog entry from GHSA. Exits non-zero ("draft
|
|
1437
1478
|
// written, please review") so CI pipelines surface the needed editorial
|
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
|
})();
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
"attack_refs": { "type": "array", "items": { "type": "string", "pattern": "^T\\d{4}(\\.\\d{3})?$" } },
|
|
152
152
|
"cve_refs": { "type": "array", "items": { "type": "string", "pattern": "^CVE-\\d{4}-\\d{4,}$" }, "description": "All CVEs must exist in data/cve-catalog.json." },
|
|
153
153
|
"cwe_refs": { "type": "array", "items": { "type": "string", "pattern": "^CWE-\\d+$" } },
|
|
154
|
-
"d3fend_refs": { "type": "array", "items": { "type": "string" } },
|
|
154
|
+
"d3fend_refs": { "type": "array", "items": { "type": "string", "pattern": "^D3-[A-Z]+$" } },
|
|
155
155
|
"frameworks_in_scope": {
|
|
156
156
|
"type": "array",
|
|
157
157
|
"items": {
|
|
@@ -300,6 +300,7 @@
|
|
|
300
300
|
"properties": {
|
|
301
301
|
"artifacts": {
|
|
302
302
|
"type": "array",
|
|
303
|
+
"minItems": 1,
|
|
303
304
|
"items": {
|
|
304
305
|
"type": "object",
|
|
305
306
|
"required": ["id", "type", "source", "description", "required"],
|
|
@@ -364,6 +365,7 @@
|
|
|
364
365
|
"properties": {
|
|
365
366
|
"indicators": {
|
|
366
367
|
"type": "array",
|
|
368
|
+
"minItems": 1,
|
|
367
369
|
"items": {
|
|
368
370
|
"type": "object",
|
|
369
371
|
"required": ["id", "type", "value", "confidence", "deterministic"],
|
package/lib/scoring.js
CHANGED
|
@@ -132,7 +132,14 @@ function validateFactors(factors) {
|
|
|
132
132
|
if (factors.blast_radius === undefined || factors.blast_radius === null) {
|
|
133
133
|
warnings.push('blast_radius: missing (treated as 0)');
|
|
134
134
|
} else if (typeof factors.blast_radius !== 'number') {
|
|
135
|
-
|
|
135
|
+
// scoreCustom coerces a numeric string (e.g. "30") via Number(); keep the
|
|
136
|
+
// two surfaces consistent — accept a finite numeric string with a soft note
|
|
137
|
+
// rather than rejecting what the scorer will happily use.
|
|
138
|
+
if (typeof factors.blast_radius === 'string' && Number.isFinite(Number(factors.blast_radius)) && factors.blast_radius.trim() !== '') {
|
|
139
|
+
warnings.push(`blast_radius: numeric string "${factors.blast_radius}" accepted (coerced to ${Number(factors.blast_radius)}); prefer a JSON number`);
|
|
140
|
+
} else {
|
|
141
|
+
warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
|
|
142
|
+
}
|
|
136
143
|
} else if (Number.isNaN(factors.blast_radius)) {
|
|
137
144
|
warnings.push('blast_radius: NaN is not a valid numeric value (treated as 0)');
|
|
138
145
|
} else if (!Number.isFinite(factors.blast_radius)) {
|