@blamejs/exceptd-skills 0.18.16 → 0.18.18

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.
@@ -185,8 +185,15 @@ function readCachedJson(cacheDir, source, id) {
185
185
  return parsed;
186
186
  }
187
187
 
188
- function extractNvdMetrics(payload) {
189
- const vuln = payload?.vulnerabilities?.[0]?.cve;
188
+ function extractNvdMetrics(payload, id) {
189
+ // Resolve the NVD vuln by id, not by position. vulnerabilities[0] blindly
190
+ // took the first record, so a cache entry keyed under `id` that held another
191
+ // CVE's response would attribute that CVE's CVSS/CWE/description to this id.
192
+ // When no id is supplied (legacy callers), fall back to the first record.
193
+ const cves = (payload?.vulnerabilities || []).map((v) => v?.cve).filter(Boolean);
194
+ const vuln = id
195
+ ? cves.find((c) => c.id && String(c.id).toUpperCase() === String(id).toUpperCase())
196
+ : cves[0];
190
197
  if (!vuln) return null;
191
198
  // Prefer the newest CVSS version (Primary within it) and normalize a bare
192
199
  // v2 vector to its canonical prefix so an auto-imported draft never carries
@@ -238,7 +245,7 @@ function extractEpss(payload, id) {
238
245
  */
239
246
  function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
240
247
  const id = String(kevEntry.cveID);
241
- const nvd = nvdPayload ? extractNvdMetrics(nvdPayload) : null;
248
+ const nvd = nvdPayload ? extractNvdMetrics(nvdPayload, id) : null;
242
249
  const epss = epssPayload ? extractEpss(epssPayload, id) : null;
243
250
 
244
251
  const knownRansomware =
@@ -63,6 +63,16 @@ const AI_KEY_VALUE_RE = {
63
63
  openai: /OPENAI_API_KEY\s*[= ]\s*['"]?(sk-[A-Za-z0-9_-]+)/,
64
64
  anthropic: /ANTHROPIC_API_KEY\s*[= ]\s*['"]?(sk-ant-[A-Za-z0-9_-]+)/,
65
65
  huggingface: /(?:HUGGINGFACE_TOKEN|HF_TOKEN)\s*[= ]\s*['"]?(hf_[A-Za-z0-9]+)/,
66
+ // Azure/Google/Cohere keys carry no vendor prefix, so the captured value IS
67
+ // the entropy body. Without these, cleartextFpIndices found no value for an
68
+ // azure/google/cohere-only dotfile, returned an empty attestation, and the
69
+ // runner downgraded a real cleartext-key hit to inconclusive — the indicator
70
+ // fired then vanished for half the supported vendors. The 30-char entropy
71
+ // floor (the `: 30` else-branch below) and PLACEHOLDER_RE both apply, since
72
+ // the prefix-strip leaves these values unchanged.
73
+ azure: /AZURE_OPENAI(?:_API)?_KEY\s*[= ]\s*['"]?([A-Za-z0-9]{20,})/,
74
+ google: /(?:GOOGLE_API_KEY|GOOGLE_GENAI_API_KEY|GEMINI_API_KEY)\s*[= ]\s*['"]?([A-Za-z0-9_-]{20,})/,
75
+ cohere: /COHERE_API_KEY\s*[= ]\s*['"]?([A-Za-z0-9-]{30,})/,
66
76
  };
67
77
  const PLACEHOLDER_RE = /placeholder|example|redacted|dummy|x{4,}|0{6,}|test-/i;
68
78
 
@@ -110,8 +110,50 @@ const CVE_CANONICAL_RE = /^CVE-\d{4}-\d{4,}$/;
110
110
  // RFC citation: `RFC 9404`, `RFC9404`, `RFC-9404`. Capture the number.
111
111
  const RFC_CITATION_RE = /RFC[\s-]?(\d{1,5})\b/gi;
112
112
 
113
- // Words that mark a catalog note as recording a rejected / disputed status.
114
- const REJECT_DISPUTE_RE = /\b(reject(?:ed|s|ion)?|disputed?|withdrawn)\b/i;
113
+ // A catalog note records a rejected/disputed RECORD for THIS CVE only when a
114
+ // reject/dispute/withdraw word refers to the citation's own record — not to a
115
+ // CVSS *scoring* disagreement, a disclosure-coordination dispute, or a
116
+ // DIFFERENT CVE the note merely mentions. A bare word-anywhere scan matched all
117
+ // three, producing a false "citation to a rejected record" hit AND a false
118
+ // __fp_checks[1] attestation (telling the runner not to downgrade), so a valid,
119
+ // often actively-exploited CVE surfaced as a confirmed rejected/disputed
120
+ // citation. `selfId` is the citation's own CVE id.
121
+ function recordRejectedOrDisputed(note, selfId) {
122
+ if (!note) return false;
123
+ const self = String(selfId || "").toUpperCase();
124
+ const re = /\b(reject(?:ed|s|ion)?|disputed?|withdrawn)\b/gi;
125
+ // Qualifier nouns that make a "dispute" a disagreement about something OTHER
126
+ // than the record's validity (the score, the severity, the disclosure
127
+ // process, …). 'rejected'/'withdrawn' are record-level words and bypass this.
128
+ const QUALIFIER = /\b(cvss|scoring|score|severity|coordination|disclosure|methodolog\w*|attribution|naming|assignment|priorit\w*)\b/i;
129
+ // A "duplicate of / superseded by / replaced by / merged into / in favour of"
130
+ // construction names the REPLACEMENT cve — THIS record is still the rejected
131
+ // one, so a different cve appearing as that replacement must NOT suppress the
132
+ // flag (e.g. "this record was rejected as a duplicate of CVE-Y").
133
+ const REPLACEMENT_OF = /\b(?:duplicate|dup)\b[\s\w-]*\bof\b|\b(?:supersed\w+|replaced|merged)\b[\s\w-]*\b(?:by|into)\b|\bin\s+favou?r\s+of\b/i;
134
+ const otherCve = (s) => (s.match(/CVE-\d{4}-\d{4,}/gi) || []).some((c) => c.toUpperCase() !== self);
135
+ let m;
136
+ while ((m = re.exec(note)) !== null) {
137
+ const word = m[1].toLowerCase();
138
+ const before = note.slice(Math.max(0, m.index - 60), m.index);
139
+ const after = note.slice(re.lastIndex, re.lastIndex + 60);
140
+ // (a) A different cve BEFORE the word is the subject ("CVE-Y was rejected")
141
+ // — the status is about that record, not this citation.
142
+ if (otherCve(before)) continue;
143
+ // (b) A different cve AFTER the word suppresses too, UNLESS it is the
144
+ // replacement target of a duplicate-of/superseded-by construction, in
145
+ // which case THIS record is the rejected one — keep it flagged.
146
+ if (otherCve(after) && !REPLACEMENT_OF.test(after)) continue;
147
+ // (c) A 'dispute(d)' qualified by a non-record noun is a disagreement about
148
+ // that noun, not a record rejection.
149
+ if (word.startsWith("disput")) {
150
+ const lastTokens = before.trim().split(/[\s-]+/).slice(-3).join(" ");
151
+ if (QUALIFIER.test(lastTokens)) continue;
152
+ }
153
+ return true;
154
+ }
155
+ return false;
156
+ }
115
157
 
116
158
  // Draft-language proximity for the (unflipped) draft-as-RFC heuristic.
117
159
  const DRAFT_LANGUAGE_RE = /\b(draft-[a-z0-9-]+|internet[- ]draft|work[- ]in[- ]progress|i-d\b)\b/i;
@@ -356,7 +398,7 @@ function collect({ cwd = process.cwd() } = {}) {
356
398
  // Well-formed. Cross-reference the catalog.
357
399
  if (cveKeys.has(full)) {
358
400
  const note = cveNotes.get(full) || "";
359
- if (REJECT_DISPUTE_RE.test(note) && !illustrative) {
401
+ if (recordRejectedOrDisputed(note, full) && !illustrative) {
360
402
  hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full, line: cveLine });
361
403
  }
362
404
  } else if (catalogsLoaded && !illustrative) {
@@ -603,4 +645,4 @@ async function applyResolution(submission, opts = {}) {
603
645
  return out;
604
646
  }
605
647
 
606
- module.exports = { playbook_id: COLLECTOR_ID, collect, applyResolution };
648
+ module.exports = { playbook_id: COLLECTOR_ID, collect, applyResolution, recordRejectedOrDisputed };
@@ -84,7 +84,16 @@ function parseAwsCredentials(content) {
84
84
  const staticKeys = {};
85
85
  for (const [name, kv] of Object.entries(profiles)) {
86
86
  const hasKey = !!kv["aws_access_key_id"];
87
- const hasFederation = !!(kv["sso_session"] || kv["credential_process"] || kv["role_arn"]);
87
+ // role_arn is NOT a federation marker for this predicate. A profile that
88
+ // carries a static aws_access_key_id (AKIA*) alongside a role_arn is the
89
+ // canonical source_profile/assume-role setup where the static key IS the
90
+ // long-lived credential that bootstraps the assumed role — a real static-key
91
+ // exposure. The playbook's aws-static-key-present predicate lists only
92
+ // sso_session / credential_process as federation; treating role_arn as
93
+ // federation here suppressed a genuinely-present IAM user key. A pure
94
+ // assume-role profile (role_arn, no own access key) is still excluded by the
95
+ // hasKey gate below.
96
+ const hasFederation = !!(kv["sso_session"] || kv["credential_process"]);
88
97
  if (hasKey && !hasFederation) {
89
98
  staticProfiles.push(name);
90
99
  staticKeys[name] = kv["aws_access_key_id"];
@@ -120,7 +120,13 @@ function isPinnedNoIntegrity(server) {
120
120
  if (typeof tok !== "string") continue;
121
121
  // npm package@version (excluding scope name@version)
122
122
  if (/@[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@\d+\.\d+\.\d+\b/.test(tok)) return true;
123
- if (/\bpip\s+install\b/.test(tok)) continue;
123
+ // A `==X.Y.Z` pin (pip/uvx/poetry) with no integrity is the python analog
124
+ // of the npm pin above. A single-token launch command like
125
+ // `sh -c "pip install some-mcp==1.2.3"` carries the whole pip invocation in
126
+ // one token, so testing the token for `pip install` and `continue`-ing here
127
+ // skipped the `==version` check below for exactly the shape the comment
128
+ // says operators care about. A bare `pip install requests` (no `==`) can't
129
+ // match the pin regex, so checking every token is safe.
124
130
  if (/==\d+\.\d+\.\d+/.test(tok)) return true;
125
131
  }
126
132
  return false;
@@ -341,6 +341,17 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
341
341
  return {
342
342
  precondition_checks: {
343
343
  "linux-platform": true,
344
+ // The collector performs a read-only inventory directly via fs (reads
345
+ // /etc/sudoers, /etc/passwd, /proc/<pid>/status) and execs no commands,
346
+ // so the `exec-allowed` precondition ('permitted to execute read-only
347
+ // inventory commands') is satisfied by construction once the collection
348
+ // reaches at least one inventory source. The runner cannot mechanically
349
+ // resolve `agent_has_command_exec == true`, so without this attestation
350
+ // the on_fail=halt preflight blocked the canonical `collect runtime | run
351
+ // runtime` pipe even with valid evidence — the sibling fs-only cred-stores
352
+ // collector attests its own halt precondition the same way. Gated on
353
+ // readability so a fully-masked /proc+/etc scope reports it false.
354
+ "exec-allowed": Boolean(sudoersReadable || passwdContent != null || procWalkable || anyTpReadable),
344
355
  },
345
356
  artifacts,
346
357
  signal_overrides,
@@ -488,7 +488,14 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
488
488
  // any .env / .env.* / .envrc with mode 0666 or 0664 (group/world writable)
489
489
  // i.e. group-write OR world-write bit set (mode & 0o022).
490
490
  const envFilePostures = process.platform === "win32" ? [] : envFiles.map(f => ({ file: f.rel, ...statPosture(f.full) }));
491
- signal_overrides["world-writable-env-file"] = envFilePostures.some(p => p.error == null && (p.mode & 0o022) !== 0) ? "hit" : "miss";
491
+ // POSIX mode bits are unreadable on win32, so this posture cannot be checked
492
+ // there. OMIT the signal on Windows (mirroring cred-stores' credentials-file-
493
+ // bad-perms) so the runner returns `inconclusive` rather than a deterministic
494
+ // false `miss` for a check that physically cannot run. (The posture array is
495
+ // still declared — empty on win32 — for the SARIF per-indicator locations.)
496
+ if (process.platform !== "win32") {
497
+ signal_overrides["world-writable-env-file"] = envFilePostures.some(p => p.error == null && (p.mode & 0o022) !== 0) ? "hit" : "miss";
498
+ }
492
499
 
493
500
  // ssh-key-bad-perms predicate (per playbook):
494
501
  // restricted to ssh-private-keys artifact + ~/.ssh/id_* paths
@@ -499,7 +506,11 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
499
506
  // (prodSshPrivateKeys) — matching ssh-private-key-block — so a fixture
500
507
  // key checked in under a test/ path doesn't raise a bad-perms posture.
501
508
  const sshKeyPostures = process.platform === "win32" ? [] : prodSshPrivateKeys.map(f => ({ file: f.rel, ...statPosture(f.full) }));
502
- signal_overrides["ssh-key-bad-perms"] = sshKeyPostures.some(p => p.error == null && p.mode !== 0o600) ? "hit" : "miss";
509
+ // Same as world-writable-env-file: omit on win32 (mode bits unreadable) so the
510
+ // runner returns inconclusive instead of a forced false `miss`.
511
+ if (process.platform !== "win32") {
512
+ signal_overrides["ssh-key-bad-perms"] = sshKeyPostures.some(p => p.error == null && p.mode !== 0o600) ? "hit" : "miss";
513
+ }
503
514
 
504
515
  // Per-indicator file locations for every indicator flipped to "hit", so
505
516
  // a SARIF result points at the file carrying the secret / bad posture.
@@ -303,13 +303,67 @@ function bySkill(skillName) {
303
303
  return { skill: skillName, summary_card: card, cve_refs: cveRefs, ttp_refs: ttpRefs };
304
304
  }
305
305
 
306
+ // global-frameworks.json is keyed by REGION (EU/UK/AU/...), each with a nested
307
+ // `frameworks: { SHORTKEY: {full_name, catalog_aliases?, ...} }` map. A flat
308
+ // `global[frameworkId]` lookup therefore ALWAYS returned null — frameworkId is a
309
+ // short key or a catalog display name, never a region — so framework_meta was
310
+ // universally null. Walk the nested structure and match the requested id against
311
+ // the short key, the full_name, or any catalog_alias (normalized), returning the
312
+ // matched framework object annotated with its region + jurisdiction.
313
+ function resolveFrameworkMeta(global, frameworkId) {
314
+ if (!global || frameworkId == null) return null;
315
+ const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\([^)]*\)/g, '').replace(/[\s_-]/g, '');
316
+ const want = norm(frameworkId);
317
+ if (!want) return null;
318
+ for (const [region, rv] of Object.entries(global)) {
319
+ if (region === '_meta' || !rv || typeof rv !== 'object') continue;
320
+ const fws = rv.frameworks || {};
321
+ for (const [shortKey, fv] of Object.entries(fws)) {
322
+ if (!fv || typeof fv !== 'object') continue;
323
+ const aliases = Array.isArray(fv.catalog_aliases) ? fv.catalog_aliases.map(norm) : [];
324
+ if (shortKey === frameworkId ||
325
+ norm(shortKey) === want ||
326
+ (fv.full_name && norm(fv.full_name) === want) ||
327
+ aliases.some((a) => a && (a === want || a.includes(want) || want.includes(a)))) {
328
+ return { ...fv, _framework_key: shortKey, _region: region, _jurisdiction: rv.jurisdiction || null };
329
+ }
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+
306
335
  function byFramework(frameworkId) {
307
336
  const gaps = loadCatalog('framework-control-gaps.json');
308
337
  const global = loadCatalog('global-frameworks.json');
338
+ const fwMeta = resolveFrameworkMeta(global, frameworkId);
339
+ // Match gap rows by the framework's full LABEL SET, not just the literal id.
340
+ // The catalog stores a framework's gaps under labels (au-ism / AU ISM / ACSC
341
+ // ISM …) that diverge from its short key and full_name, so an exact
342
+ // `g.framework === frameworkId` match returned a gap set inconsistent with the
343
+ // (now alias-resolved) framework_meta — non-null metadata but a partial,
344
+ // sometimes empty, gap list. Resolve the same alias set used for the metadata
345
+ // and match any gap label (string or array element) against it.
346
+ const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\([^)]*\)/g, '').replace(/[\s_-]/g, '');
347
+ const labels = new Set([norm(frameworkId)]);
348
+ if (fwMeta) {
349
+ if (fwMeta._framework_key) labels.add(norm(fwMeta._framework_key));
350
+ if (fwMeta.full_name) labels.add(norm(fwMeta.full_name));
351
+ for (const a of (Array.isArray(fwMeta.catalog_aliases) ? fwMeta.catalog_aliases : [])) labels.add(norm(a));
352
+ }
353
+ labels.delete('');
354
+ const labelMatch = (fw) => {
355
+ const n = norm(fw);
356
+ if (!n) return false;
357
+ for (const l of labels) if (n === l || n.includes(l) || l.includes(n)) return true;
358
+ return false;
359
+ };
309
360
  const matching = entries(gaps)
310
- .filter(([, g]) => g.framework === frameworkId || g.framework === 'ALL')
361
+ .filter(([, g]) => {
362
+ if (g.framework === 'ALL') return true;
363
+ const fwList = Array.isArray(g.framework) ? g.framework : [g.framework];
364
+ return fwList.some(labelMatch);
365
+ })
311
366
  .map(([id, g]) => ({ id, ...g }));
312
- const fwMeta = global[frameworkId] || null;
313
367
  return { framework: frameworkId, framework_meta: fwMeta, gaps: matching, gap_count: matching.length };
314
368
  }
315
369
 
@@ -108,6 +108,17 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
108
108
  const normalize = (s) => String(s).toLowerCase().replace(/[\s_-]/g, '');
109
109
  const idNorm = normalize(frameworkId);
110
110
  const nameNorm = frameworkData?.full_name ? normalize(frameworkData.full_name) : null;
111
+ // Data-driven aliases close the naming divergence between a framework's
112
+ // global-frameworks full_name and the (often different) labels the control-gap
113
+ // catalog uses for it — e.g. ASD_ISM's full_name is "Australian Signals
114
+ // Directorate Information Security Manual" but the catalog labels its 5 gaps
115
+ // "au-ism" / "ACSC ISM" / "Australian Government Information Security Manual",
116
+ // so neither nameNorm nor idNorm matched and lagScore reported 0 gaps. Aliases
117
+ // live in data/global-frameworks.json (catalog_aliases) so this generalizes to
118
+ // any future framework whose catalog label diverges from its name.
119
+ const aliasNorms = Array.isArray(frameworkData?.catalog_aliases)
120
+ ? frameworkData.catalog_aliases.map((a) => normalize(a)).filter(Boolean)
121
+ : [];
111
122
  const gaps = Object.entries(controlGaps).filter(([key, g]) => {
112
123
  if (key.startsWith('_')) return false;
113
124
  if (g.status !== 'open' || !g.framework) return false;
@@ -116,10 +127,17 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
116
127
  const fwNorm = normalize(fw);
117
128
  if (nameNorm && fwNorm.includes(nameNorm)) return true; // display-name match
118
129
  if (fwNorm.includes(idNorm)) return true; // short-key substring
130
+ if (aliasNorms.some((a) => a && (fwNorm.includes(a) || a.includes(fwNorm)))) return true; // catalog-label alias
119
131
  }
120
132
  if (normalize(key).startsWith(idNorm)) return true; // gap-key prefix
121
133
  return false;
122
134
  });
135
+ // Observable backstop: a framework that EXISTS in global-frameworks resolving
136
+ // to zero catalog gaps is almost always a fresh naming divergence (a new
137
+ // catalog label not yet in catalog_aliases), not a genuinely gap-free
138
+ // framework. Surface it as a structured flag rather than a silent 0 so a
139
+ // regression is greppable in the breakdown instead of invisible.
140
+ const resolvedButZeroGaps = Boolean(frameworkData && gaps.length === 0);
123
141
 
124
142
  const universalGaps = Object.values(controlGaps).filter(g =>
125
143
  g.framework === 'ALL' && g.status === 'open'
@@ -149,7 +167,10 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
149
167
  ai_coverage: { coverage: frameworkData?.ai_coverage ?? 'unknown', score: aiCoverageScore },
150
168
  pqc_coverage: { coverage: frameworkData?.pqc_coverage ?? 'unknown', score: pqcScore },
151
169
  universal_gaps: { count: universalGaps.length, score: universalGapScore },
152
- framework_specific_gaps: gaps.length
170
+ framework_specific_gaps: gaps.length,
171
+ // True only when the framework resolves in global-frameworks yet matched
172
+ // zero catalog gaps — a likely naming divergence worth investigating.
173
+ framework_resolved_but_zero_gaps: resolvedButZeroGaps
153
174
  }
154
175
  };
155
176
  }
@@ -4137,6 +4137,36 @@ function canonicalStringify(v, _depth = 0) {
4137
4137
  return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k], _depth + 1)).join(',') + '}';
4138
4138
  }
4139
4139
 
4140
+ // Re-key an artifacts map by the stable indicator id each artifact was bound to
4141
+ // (recovered by inverting _signal_origins: indicator-id -> observation-key), so
4142
+ // the evidence_hash reflects the evidence VALUE + its stable binding rather than
4143
+ // the operator's free-text observation label. Without this, two submissions with
4144
+ // identical (indicator, value) evidence under different observation keys
4145
+ // (obs-kver vs x1) hashed differently and attest/reattest reported false drift —
4146
+ // the attest diff re-keys the COMPARISON (bin/exceptd.js); the hash itself was
4147
+ // not covered. Collision-safe (mirrors that helper): re-key only when the stable
4148
+ // id is not already a DISTINCT original key and has not been claimed by an
4149
+ // earlier entry, else keep the original key so no artifact is silently dropped.
4150
+ function _rekeyArtifactsByStableId(artifacts, signalOrigins) {
4151
+ // The sole caller (extractSubmissionForHash) only invokes this inside
4152
+ // `if (sub.artifacts && typeof sub.artifacts === 'object')`, so `artifacts`
4153
+ // is always a non-null object — guard only on the re-key map being usable.
4154
+ if (!signalOrigins || typeof signalOrigins !== 'object') return artifacts;
4155
+ const obsKeyToIndicator = {};
4156
+ for (const [indicatorId, obsKey] of Object.entries(signalOrigins)) {
4157
+ if (typeof obsKey === 'string') obsKeyToIndicator[obsKey] = indicatorId;
4158
+ }
4159
+ const originalKeys = new Set(Object.keys(artifacts));
4160
+ const out = {};
4161
+ for (const [k, v] of Object.entries(artifacts)) {
4162
+ const mapped = obsKeyToIndicator[k];
4163
+ const stable = (mapped && mapped !== k && !originalKeys.has(mapped)
4164
+ && !Object.prototype.hasOwnProperty.call(out, mapped)) ? mapped : k;
4165
+ out[stable] = v;
4166
+ }
4167
+ return out;
4168
+ }
4169
+
4140
4170
  /**
4141
4171
  * Pick the operator-meaningful fields out of the normalized submission
4142
4172
  * for hashing. captured_at, _signal_origins, _signal_origins_collisions,
@@ -4152,15 +4182,20 @@ function extractSubmissionForHash(sub) {
4152
4182
  // optional indicator binding) is what matters for "did the operator
4153
4183
  // submit the same evidence?".
4154
4184
  if (sub.artifacts && typeof sub.artifacts === 'object') {
4155
- pick.artifacts = {};
4185
+ const stripped = {};
4156
4186
  for (const [k, v] of Object.entries(sub.artifacts)) {
4157
4187
  if (v && typeof v === 'object') {
4158
4188
  const { captured_at, _captured_at, ...rest } = v;
4159
- pick.artifacts[k] = rest;
4189
+ stripped[k] = rest;
4160
4190
  } else {
4161
- pick.artifacts[k] = v;
4191
+ stripped[k] = v;
4162
4192
  }
4163
4193
  }
4194
+ // Re-key by the stable indicator id so the digest reflects the evidence
4195
+ // VALUE + its binding, never the operator's free-text observation label.
4196
+ // Identical (indicator, value) evidence under different observation keys
4197
+ // must produce the SAME evidence_hash / submission_digest / session_id.
4198
+ pick.artifacts = _rekeyArtifactsByStableId(stripped, sub._signal_origins);
4164
4199
  }
4165
4200
  if (sub.signal_overrides && typeof sub.signal_overrides === 'object') {
4166
4201
  pick.signal_overrides = sub.signal_overrides;
package/lib/prefetch.js CHANGED
@@ -39,7 +39,7 @@
39
39
  const fs = require("fs");
40
40
  const path = require("path");
41
41
  const crypto = require("crypto");
42
- const { JobQueue } = require("./job-queue");
42
+ const { JobQueue, isRetryable } = require("./job-queue");
43
43
 
44
44
  const ROOT = path.join(__dirname, "..");
45
45
  const DEFAULT_CACHE = path.join(ROOT, ".cache", "upstream");
@@ -220,11 +220,21 @@ function errorBudget(maxErrors, planned) {
220
220
  }
221
221
 
222
222
  // Decide prefetch's 0-vs-1 exit code from a completed run. Per-entry fetch
223
- // errors are counted only after the job queue exhausts its retries, so they
224
- // are genuine failures — but a best-effort cache warm should tolerate a few
225
- // transient upstream misses. `opts.maxErrors` is the budget (default 0, so any
226
- // error exits 1 — the strict contract a manual operator expects). Fatal
227
- // errors (bad flags, an unhandled throw) are handled in main() and exit 2.
223
+ // errors are counted only after the job queue exhausts its retries. They split
224
+ // into two classes that mean very different things for a best-effort cache
225
+ // warm:
226
+ // - HARD errors (404/410/parse failure/4xx-not-429) are real data faults.
227
+ // These count toward `opts.maxErrors` (default 0, so any hard error exits
228
+ // 1 — the strict contract a manual operator expects).
229
+ // - TRANSIENT errors (HTTP 408/425/429/5xx + ETIMEDOUT/ECONNRESET et al that
230
+ // exhausted their retry budget) are the upstream throttling us, not a data
231
+ // fault. They are surfaced in the summary and deferred to the next run
232
+ // (where the entries that DID land this run are fresh-skipped, freeing rate
233
+ // budget for the throttled ones) — they never fail the run on their own.
234
+ // Without the split, a daily NVD rate-limit on a subset of CVEs hard-failed the
235
+ // whole scheduled refresh and skipped the auto-PR every single run, since the
236
+ // ephemeral runner cache restarts cold each time and re-hits the same throttle.
237
+ // Fatal errors (bad flags, an unhandled throw) are handled in main() and exit 2.
228
238
  function exitCodeForResult(result, opts = {}) {
229
239
  const errors = (result && result.errors) || 0;
230
240
  if (errors === 0) return 0;
@@ -232,16 +242,22 @@ function exitCodeForResult(result, opts = {}) {
232
242
  // and nothing already fresh in the cache, yet errors recorded — is entirely
233
243
  // unreachable, and the refresh would silently skip it. A dead KEV feed is
234
244
  // only one error (well under any global budget) but means the run missed
235
- // every new KEV flag. Fail regardless of the budget so a single fully-dead
236
- // feed can't pass quietly.
245
+ // every new KEV flag. Fail regardless of the budget OR error class so a
246
+ // single fully-dead feed (incl. an NVD that 429s/503s every single request)
247
+ // can't pass quietly.
237
248
  const bySource = (result && result.by_source) || {};
238
249
  for (const s of Object.values(bySource)) {
239
250
  if (s && (s.errors || 0) > 0 && (s.fetched || 0) === 0 && (s.skipped_fresh || 0) === 0) {
240
251
  return 1;
241
252
  }
242
253
  }
254
+ // Only HARD errors gate the exit code against the budget. Back-compat: a
255
+ // result built without the split (errors_hard undefined) treats every error
256
+ // as hard, preserving the prior strict behavior for callers/tests that
257
+ // construct a bare { errors } result.
258
+ const hardErrors = (result && result.errors_hard != null) ? result.errors_hard : errors;
243
259
  const budget = errorBudget(opts.maxErrors, plannedCount(result));
244
- return errors > budget ? 1 : 0;
260
+ return hardErrors > budget ? 1 : 0;
245
261
  }
246
262
 
247
263
  // One-line run summary. When a run has errors, names the per-source counts so
@@ -254,6 +270,12 @@ function formatSummary(result, opts = {}) {
254
270
  .map(([name, s]) => `${name}=${s.errors}`);
255
271
  if (parts.length) line += ` [${parts.join(", ")}]`;
256
272
  }
273
+ // Name the transient vs hard split when present so a large error count that
274
+ // is purely upstream throttling reads as "throttled — retried, deferred to
275
+ // next run" rather than a silent data failure. Only hard errors gate exit 1.
276
+ if (result.errors > 0 && result.errors_transient != null && result.errors_hard != null) {
277
+ line += ` (${result.errors_transient} transient/throttled, ${result.errors_hard} hard)`;
278
+ }
257
279
  if (opts.noNetwork) line += " (dry-run)";
258
280
  return line;
259
281
  }
@@ -275,8 +297,11 @@ Options:
275
297
  --no-network report-only; list what would be fetched.
276
298
  --cache-dir <path> override cache root (default .cache/upstream).
277
299
  --quiet suppress per-entry log lines.
278
- --max-errors <n|n%> tolerate up to n (or n% of planned) per-entry fetch
279
- errors before exit 1. Default: 0 (any error exits 1).
300
+ --max-errors <n|n%> tolerate up to n (or n% of planned) HARD per-entry fetch
301
+ errors before exit 1. Default: 0 (any hard error exits 1).
302
+ Transient errors (rate-limit / timeout / 5xx that
303
+ exhausted retries) never fail the run on their own — they
304
+ are surfaced in the summary and retried on the next run.
280
305
  A fully-dead source still exits 1 regardless of budget.
281
306
 
282
307
  Use NVD_API_KEY / GITHUB_TOKEN env vars to lift rate limits.
@@ -599,6 +624,12 @@ function authHeadersForSource(source) {
599
624
 
600
625
  async function prefetch(options = {}) {
601
626
  const opts = { maxAgeMs: 24 * 3600 * 1000, source: null, force: false, noNetwork: false, cacheDir: DEFAULT_CACHE, quiet: false, ...options };
627
+ // Honor the global air-gap switch for programmatic callers too. parseArgs
628
+ // applies EXCEPTD_AIR_GAP for the CLI path, but a direct prefetch({...}) call
629
+ // bypasses parseArgs — so without this guard an air-gapped host that imports
630
+ // and calls prefetch() would egress live. Bind it here, at the function that
631
+ // actually issues the fetches, covering both the CLI and exported-API callers.
632
+ if (process.env.EXCEPTD_AIR_GAP === "1" || opts.airGap) opts.noNetwork = true;
602
633
  const ctx = loadCtx();
603
634
  // Distinguish "operator omitted --source" (resolve to all sources, the
604
635
  // documented default) from "operator passed --source but it resolved to
@@ -659,8 +690,8 @@ async function prefetch(options = {}) {
659
690
  log(`Cache dir: ${path.relative(ROOT, opts.cacheDir)}`);
660
691
  log(`Max age: ${(opts.maxAgeMs / 3_600_000).toFixed(1)}h${opts.force ? " (forced)" : ""}`);
661
692
 
662
- const result = { fetched: 0, skipped_fresh: 0, errors: 0, by_source: {} };
663
- for (const s of chosen) result.by_source[s] = { fetched: 0, skipped_fresh: 0, errors: 0 };
693
+ const result = { fetched: 0, skipped_fresh: 0, errors: 0, errors_transient: 0, errors_hard: 0, by_source: {} };
694
+ for (const s of chosen) result.by_source[s] = { fetched: 0, skipped_fresh: 0, errors: 0, errors_transient: 0, errors_hard: 0 };
664
695
 
665
696
  if (opts.noNetwork) {
666
697
  for (const item of plan) {
@@ -756,10 +787,25 @@ async function prefetch(options = {}) {
756
787
  .catch((err) => {
757
788
  result.errors++;
758
789
  result.by_source[item.source].errors++;
790
+ // Classify the post-retry error. Transient iff the job queue would
791
+ // have retried it (the same isRetryable classifier the queue used):
792
+ // HTTP 408/425/429/5xx + ETIMEDOUT/ECONNRESET et al — the upstream
793
+ // throttling/timing-out, not a data fault. Anything else (404/410/
794
+ // parse failure) is hard. Only hard errors gate the exit code; a
795
+ // best-effort warm tolerates transient throttling and retries it on
796
+ // the next run. The split is surfaced in the summary so nothing hides.
797
+ const transient = isRetryable(err);
798
+ if (transient) {
799
+ result.errors_transient++;
800
+ result.by_source[item.source].errors_transient++;
801
+ } else {
802
+ result.errors_hard++;
803
+ result.by_source[item.source].errors_hard++;
804
+ }
759
805
  // Errors go to stderr unconditionally — they are diagnostics, not the
760
806
  // per-entry success chatter --quiet suppresses. A CI run with --quiet
761
- // still surfaces which source/id failed.
762
- console.error(` [${item.source}] ${item.id} — error: ${err.message}`);
807
+ // still surfaces which source/id failed and whether it was transient.
808
+ console.error(` [${item.source}] ${item.id} — ${transient ? "transient" : "hard"} error: ${err.message}`);
763
809
  });
764
810
  });
765
811
 
@@ -1023,7 +1023,13 @@ function epssDiffFromCache(ctx) {
1023
1023
  for (const id of cves) {
1024
1024
  const payload = readCachedJson(ctx.cacheDir, "epss", id, { forceStale: ctx.forceStale });
1025
1025
  if (!payload) { errors++; continue; }
1026
- const row = (payload.data || []).find((r) => r?.cve === id) || (payload.data || [])[0];
1026
+ // Match the EPSS row by id. The old `|| data[0]` blanket fallback
1027
+ // attributed a DIFFERENT CVE's score to this id whenever the cache entry
1028
+ // keyed under `id` actually held another CVE's payload. Accept the
1029
+ // single-row fallback ONLY when that row carries no cve key (a keyless
1030
+ // legacy payload), never a row naming a different CVE.
1031
+ let row = (payload.data || []).find((r) => r?.cve === id);
1032
+ if (!row && (payload.data || []).length === 1 && (payload.data || [])[0]?.cve == null) row = (payload.data || [])[0];
1027
1033
  if (!row) continue;
1028
1034
  const score = row.epss != null ? Number(row.epss) : null;
1029
1035
  const pct = row.percentile != null ? Number(row.percentile) : null;
@@ -1051,7 +1057,15 @@ function nvdDiffFromCache(ctx) {
1051
1057
  for (const id of cves) {
1052
1058
  const payload = readCachedJson(ctx.cacheDir, "nvd", id, { forceStale: ctx.forceStale });
1053
1059
  if (!payload) { errors++; continue; }
1054
- const vuln = payload.vulnerabilities?.[0]?.cve;
1060
+ // Resolve the NVD vuln by id, not by position. vulnerabilities[0] blindly
1061
+ // took the first record, attributing a DIFFERENT CVE's CVSS to this id when
1062
+ // the cache entry keyed under `id` held another CVE's response.
1063
+ const vulnCves = (payload.vulnerabilities || []).map((v) => v?.cve).filter(Boolean);
1064
+ let vuln = vulnCves.find((c) => c.id && String(c.id).toUpperCase() === id.toUpperCase());
1065
+ // Keyless single-record fallback: a single vulnerability whose cve carries
1066
+ // no id can't be a mismatch — the id-keyed cache file IS the binding — so
1067
+ // use it. A record whose id names a DIFFERENT cve is still rejected.
1068
+ if (!vuln && vulnCves.length === 1 && vulnCves[0].id == null) vuln = vulnCves[0];
1055
1069
  if (!vuln) continue;
1056
1070
  // Prefer the newest CVSS version NVD publishes (Primary within that
1057
1071
  // version), and normalize a bare v2 vector to its canonical prefix.
package/lib/scoring.js CHANGED
@@ -520,7 +520,22 @@ function compare(cveId, catalog, opts) {
520
520
  if (entry.cisa_kev) driving.push('CISA KEV (+25)');
521
521
  if (entry.poc_available) driving.push('public PoC (+20)');
522
522
  if (entry.ai_discovered || entry.ai_assisted_weaponization) driving.push('AI-discovered (+15 weaponization)');
523
- if (String(entry.active_exploitation || '').trim().toLowerCase() === 'confirmed') driving.push('confirmed exploitation (+20)');
523
+ // active_exploitation via the SAME ladder scoreCustom uses, so suspected
524
+ // (+10) and unknown (+5) are listed with their actual contribution rather
525
+ // than only 'confirmed' (+20). The bare confirmed-only test dropped every
526
+ // suspected/unknown driver — so the enumerated factors summed to less than
527
+ // the delta, or (on a purely suspected/blast-driven entry) to nothing,
528
+ // leaving a dangling "driving delta: .".
529
+ const ae = resolveActiveExploitation(entry.active_exploitation);
530
+ if (ae.multiplier > 0) {
531
+ driving.push(`${ae.normalised} exploitation (+${Math.round(RWEP_WEIGHTS.active_exploitation * ae.multiplier)})`);
532
+ }
533
+ // blast_radius contributes its raw value (0..30) to RWEP but never appeared
534
+ // in the driver list, so a blast-driven delta showed a higher RWEP with no
535
+ // stated cause. List the clamped contribution when positive.
536
+ const blastRaw = Number((entry.rwep_factors || {}).blast_radius);
537
+ const blast = Number.isFinite(blastRaw) ? Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, blastRaw)) : 0;
538
+ if (blast > 0) driving.push(`blast radius (+${Math.round(blast)})`);
524
539
  // Mirror scoreCustom's rebootFactor EXACTLY: the +5 reboot weight is added
525
540
  // whenever a reboot is required, regardless of live_patch_available (a live
526
541
  // patch is a temporary workaround; the full-remediation window still extends
@@ -529,7 +544,9 @@ function compare(cveId, catalog, opts) {
529
544
  // delta on any entry that both requires a reboot AND has a live patch
530
545
  // available, hiding a driver the score actually counted.
531
546
  if (entry.reboot_required || entry.patch_required_reboot) driving.push('reboot required (+5)');
532
- explanation += driving.join(', ');
547
+ // A positive delta with no enumerated driver still names the structural
548
+ // cause instead of a dangling "driving delta: .".
549
+ explanation += driving.length ? driving.join(', ') : 'blast magnitude / structural RWEP factors';
533
550
  explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
534
551
  } else if (delta < -10) {
535
552
  explanation = `RWEP lower than CVSS equivalent. Mitigating factors: `;
@@ -538,7 +555,9 @@ function compare(cveId, catalog, opts) {
538
555
  if (entry.live_patch_available) mitigating.push('live patch available (-10)');
539
556
  if (!entry.poc_available) mitigating.push('no public PoC');
540
557
  if (!entry.cisa_kev) mitigating.push('not CISA KEV');
541
- explanation += mitigating.join(', ');
558
+ // A negative delta with no enumerated mitigator still names the structural
559
+ // cause (high CVSS base vs. a modest RWEP) instead of a dangling list.
560
+ explanation += mitigating.length ? mitigating.join(', ') : 'high CVSS base vs. a modest RWEP (low blast radius / no exploitation signal)';
542
561
  } else {
543
562
  explanation = 'CVSS and RWEP are broadly aligned for this CVE.';
544
563
  }
@@ -49,11 +49,14 @@ function ghsaResponseCapBytes() {
49
49
  * local value has gone null. Mirrors lib/source-osv.js. Finding 9.
50
50
  */
51
51
  const FIELD_DROPPED_WATCH = Object.freeze([
52
+ // Only fields the upstream feed actually populates can legitimately "drop"
53
+ // from populated -> null on a re-import. active_exploitation / ai_discovered /
54
+ // poc_available are EDITORIAL (curated by hand, never upstream-sourced), so
55
+ // the normalize step always nulls them — watching them flagged a field_dropped
56
+ // "regression" on every single curated re-import. Restrict the watch to the
57
+ // upstream-sourced fields.
52
58
  "cvss_score",
53
59
  "cisa_kev_pending",
54
- "active_exploitation",
55
- "ai_discovered",
56
- "poc_available",
57
60
  ]);
58
61
 
59
62
  /**
@@ -460,12 +463,17 @@ async function buildDiff(ctx) {
460
463
  source: "ghsa",
461
464
  });
462
465
  }
466
+ // diffs holds BOTH _new_entry and field_dropped records — count them
467
+ // separately so the summary doesn't mislabel field-dropped regressions as
468
+ // brand-new CVE IDs.
469
+ const newCount = diffs.filter((d) => d.field === "_new_entry").length;
470
+ const droppedCount = diffs.filter((d) => d.variant === "field_dropped").length;
463
471
  return {
464
472
  status: "ok",
465
473
  diffs,
466
474
  errors: 0,
467
475
  ghsa_only_skipped: ghsaOnlySkipped,
468
- summary: `GHSA returned ${result.advisories.length} reviewed advisories; ${diffs.length} new CVE ID(s) not yet in local catalog, ${ghsaOnlySkipped} ghsa_only_skipped.`,
476
+ summary: `GHSA returned ${result.advisories.length} reviewed advisories; ${newCount} new CVE ID(s) not yet in local catalog, ${droppedCount} field-dropped regression(s), ${ghsaOnlySkipped} ghsa_only_skipped.`,
469
477
  rate_limit: result.rate_limit || null,
470
478
  };
471
479
  }