@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 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
- const runSigs = !anySelected || onlySigs;
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
- // Audit 3 B.7: surface the RFC catalog file's mtime + age so
6677
- // operators can answer "is the offline RFC index fresh?" without
6678
- // running a separate refresh.
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 rfcPath = path.join(PKG_ROOT, "data", "rfc-index.json");
6683
- const st = fs.statSync(rfcPath);
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 not exist on contributor checkouts */ }
6700
+ } catch { /* file may be absent on exotic installs — total stays 0 */ }
6687
6701
  checks.rfcs = {
6688
6702
  ok,
6689
- total: rfcRows,
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: errorFindings.length > 0 && fixesFailed > 0 ? 'warn' : (errorFindings.length > 0 && !args.fix ? 'warn' : 'info'),
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
- const { warnList, errorList } = bucketChecks(checks);
7093
- const allGreen = errorList.length === 0 && warnList.length === 0;
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
- emit({ verb: "ci", session_id: sessionId, format: fmt, bundles_count: bundles.length, bundles }, pretty);
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
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-27T20:36:13.795Z",
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": "3db842fa75688111c96edd57712b9447a3df84cb250df1e052ac45b38aff74f2",
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
- const body = { ok: true, verb: "cve", ...r };
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
- // A citation that won't stand up is a non-zero exit so a CI/script gate trips.
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
  })();
@@ -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: 'Active exploitation confirmed (CISA KEV).' }] : [],
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: 'csaf_security_advisory',
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
- const cveResults = analyze.matched_cves.map(c => ({
2566
- ruleId: `${rulePrefix}${c.cve_id}`,
2567
- level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
2568
- message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
2569
- properties: stripNulls({
2570
- kind: 'cve_match',
2571
- rwep: c.rwep,
2572
- cisa_kev: c.cisa_kev,
2573
- cisa_kev_due_date: c.cisa_kev_due_date ?? null,
2574
- active_exploitation: c.active_exploitation ?? null,
2575
- ai_discovered: c.ai_discovered ?? null,
2576
- blast_radius_score: analyze.blast_radius_score,
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
- format: 'openvex',
2773
- message: 'OpenVEX author falls back to urn:exceptd:operator:unknown supply runOpts.operator or runOpts.publisherNamespace to claim disposition attribution.',
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
@@ -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
- const body = { ok: true, verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}) };
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 (r.status === "nonexistent" || titleMatch === false) process.exitCode = 2;
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
- warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
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)) {