@blamejs/exceptd-skills 0.18.15 → 0.18.17

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.
@@ -1994,14 +1994,26 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1994
1994
  const triggered = evalCondition(c.exception_generation.trigger_condition, closeEvalCtx, playbook);
1995
1995
  if (triggered) {
1996
1996
  const t = c.exception_generation.exception_template;
1997
+ // Track unresolved ${placeholder} tokens the SAME way the draft_notification
1998
+ // render does (missing_interpolation_vars). Exception-template tokens are
1999
+ // operator-fill values (affected_host_count, compensating_controls,
2000
+ // patch_available_status, …) that analyzeFindingShape does NOT supply and
2001
+ // that operators rarely pre-stage on the standard `exceptd run` path, so the
2002
+ // auditor-ready risk-acceptance language otherwise ships literal
2003
+ // `<MISSING:…>` tokens with no machine-readable signal of which placeholders
2004
+ // failed. Surface them as exception.missing_interpolation_vars (empty when
2005
+ // all resolved) so a `ci`/JSON consumer — and the operator filling the
2006
+ // exception — sees exactly what still needs a value.
2007
+ const exMissing = [];
2008
+ const findingShape = (() => { try { return analyzeFindingShape(analyzeResult); } catch { return {}; } })();
1997
2009
  exception = {
1998
- scope: interpolate(t.scope, { ...agentSignals, ...analyzeFindingShape(analyzeResult) }),
2010
+ scope: interpolate(t.scope, { ...agentSignals, ...findingShape }, exMissing),
1999
2011
  duration: t.duration,
2000
2012
  compensating_controls: t.compensating_controls,
2001
2013
  risk_acceptance_owner: t.risk_acceptance_owner,
2002
2014
  auditor_ready_language: interpolate(t.auditor_ready_language, {
2003
2015
  ...agentSignals,
2004
- ...analyzeFindingShape(analyzeResult),
2016
+ ...findingShape,
2005
2017
  framework_id: playbook.domain.frameworks_in_scope[0] || 'unspecified',
2006
2018
  control_id: analyzeResult.framework_gap_mapping?.[0]?.claimed_control || 'unspecified',
2007
2019
  ciso_name: agentSignals.ciso_name || '<CISO NAME>',
@@ -2010,8 +2022,16 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2010
2022
  // same auditor-facing date.
2011
2023
  acceptance_date: (deterministic ? frozenEpoch : new Date().toISOString()).slice(0, 10),
2012
2024
  duration_expiry: agentSignals.duration_expiry || 'until vendor patch'
2013
- })
2025
+ }, exMissing),
2026
+ missing_interpolation_vars: exMissing,
2014
2027
  };
2028
+ // Make the unresolved-placeholder gap observable to JSON/ci consumers, not
2029
+ // just present on the exception object.
2030
+ if (exMissing.length && Array.isArray(runOpts._runErrors)) {
2031
+ pushRunError(runOpts._runErrors,
2032
+ { kind: 'exception_unresolved_placeholders', placeholders: exMissing.slice() },
2033
+ { dedupeKey: e => (e.placeholders || []).join(',') });
2034
+ }
2015
2035
  }
2016
2036
  }
2017
2037
 
@@ -2330,12 +2350,23 @@ function vulnIdToUrn(id) {
2330
2350
  function buildCsafBranches(matchedCves, runOpts) {
2331
2351
  // Build a (vendor → product → Set<version>) map.
2332
2352
  const tree = new Map();
2333
- const addLeaf = (vendor, product, version) => {
2353
+ // Track which CVE contributed each leaf so the emitted CSAFPID-N product_ids
2354
+ // can be bound back into that CVE's vulnerabilities[].product_status — without
2355
+ // this the branch tree's per-version products are referenced by nothing and a
2356
+ // CSAF consumer correlating product_status to the tree resolves only the
2357
+ // generic synthetic product. leafKey = vendor\0product\0version.
2358
+ const leafKey = (vendor, product, version) => JSON.stringify([vendor, product, version]);
2359
+ const cveLeaves = new Map(); // cve_id → Set<leafKey>
2360
+ const addLeaf = (vendor, product, version, cveId) => {
2334
2361
  if (!vendor || !product || !version) return;
2335
2362
  if (!tree.has(vendor)) tree.set(vendor, new Map());
2336
2363
  const products = tree.get(vendor);
2337
2364
  if (!products.has(product)) products.set(product, new Set());
2338
2365
  products.get(product).add(version);
2366
+ if (cveId) {
2367
+ if (!cveLeaves.has(cveId)) cveLeaves.set(cveId, new Set());
2368
+ cveLeaves.get(cveId).add(leafKey(vendor, product, version));
2369
+ }
2339
2370
  };
2340
2371
 
2341
2372
  // Comparison / range operators that appear between a package name and a
@@ -2386,7 +2417,7 @@ function buildCsafBranches(matchedCves, runOpts) {
2386
2417
  if (Array.isArray(c.affected_products) && c.affected_products.length > 0) {
2387
2418
  for (const ap of c.affected_products) {
2388
2419
  if (ap && typeof ap === 'object' && ap.vendor && ap.product && ap.version) {
2389
- addLeaf(String(ap.vendor), String(ap.product), String(ap.version));
2420
+ addLeaf(String(ap.vendor), String(ap.product), String(ap.version), c.cve_id);
2390
2421
  }
2391
2422
  }
2392
2423
  continue;
@@ -2396,7 +2427,7 @@ function buildCsafBranches(matchedCves, runOpts) {
2396
2427
  for (const comp of components) {
2397
2428
  const parsed = parseComponentString(comp);
2398
2429
  if (parsed) {
2399
- addLeaf(parsed.vendor, parsed.product, parsed.version);
2430
+ addLeaf(parsed.vendor, parsed.product, parsed.version, c.cve_id);
2400
2431
  } else if (typeof comp === 'string' && comp.trim() && runOpts && Array.isArray(runOpts._runErrors)) {
2401
2432
  pushRunError(runOpts._runErrors, {
2402
2433
  kind: 'csaf_branch_unparseable',
@@ -2409,6 +2440,7 @@ function buildCsafBranches(matchedCves, runOpts) {
2409
2440
 
2410
2441
  // Sort + emit.
2411
2442
  const productIds = [];
2443
+ const leafKeyToPid = new Map();
2412
2444
  let pidCounter = 0;
2413
2445
  const vendors = Array.from(tree.keys()).sort();
2414
2446
  const branches = vendors.map(vendor => {
@@ -2425,6 +2457,7 @@ function buildCsafBranches(matchedCves, runOpts) {
2425
2457
  branches: versions.map(version => {
2426
2458
  const pid = `CSAFPID-${pidCounter++}`;
2427
2459
  productIds.push({ vendor, product, version, product_id: pid });
2460
+ leafKeyToPid.set(leafKey(vendor, product, version), pid);
2428
2461
  return {
2429
2462
  category: 'product_version',
2430
2463
  name: version,
@@ -2438,7 +2471,15 @@ function buildCsafBranches(matchedCves, runOpts) {
2438
2471
  }),
2439
2472
  };
2440
2473
  });
2441
- return { branches, productIds };
2474
+ // Per-CVE CSAFPID binding so the caller can add the version leaves to each
2475
+ // vulnerability's product_status.known_affected.
2476
+ const cveProductIds = {};
2477
+ for (const [cveId, keys] of cveLeaves.entries()) {
2478
+ const pids = [];
2479
+ for (const k of keys) { const pid = leafKeyToPid.get(k); if (pid) pids.push(pid); }
2480
+ if (pids.length) cveProductIds[cveId] = pids.sort();
2481
+ }
2482
+ return { branches, productIds, cveProductIds };
2442
2483
  }
2443
2484
 
2444
2485
  // Slugify a string into a URN-safe segment (RFC 8141 NSS). Empty input →
@@ -2545,6 +2586,24 @@ function sarifLocationsForIndicator(playbook, indicator) {
2545
2586
  return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
2546
2587
  }
2547
2588
 
2589
+ // Locations for a finding-class SARIF result, with a guaranteed non-empty
2590
+ // fallback. sarifLocationsForIndicator returns a PHYSICAL location only when the
2591
+ // agent supplied evidence_locations or the playbook's look-artifact source is a
2592
+ // bare path token; the dominant catalog shape is a prose / glob / shell-command
2593
+ // source (which carries whitespace and is rejected by looksLikePath), so the
2594
+ // physical fallback is null for ~most playbooks. A SARIF result with no
2595
+ // `locations[]` is silently DROPPED by GitHub Code Scanning — so a result with
2596
+ // no concrete file still gets a `logicalLocations` entry naming its rule, which
2597
+ // is SARIF-conformant (§3.33), keeps the finding attributable + visible in the
2598
+ // alerts list and in SARIF viewers, and is honest (there is no physical file to
2599
+ // point at when the agent supplied no evidence location). Physical location is
2600
+ // always preferred when available.
2601
+ function sarifResultLocations(playbook, indicator, fqRuleId) {
2602
+ const phys = sarifLocationsForIndicator(playbook, indicator);
2603
+ if (phys && phys.length) return phys;
2604
+ return [{ logicalLocations: [{ name: fqRuleId, fullyQualifiedName: fqRuleId, kind: 'rule' }] }];
2605
+ }
2606
+
2548
2607
  // Resolve the package version once per process so CSAF tracking.generator
2549
2608
  // can name the engine that emitted the advisory. Best-effort read — bundle
2550
2609
  // emission must not crash if package.json is missing (e.g. exotic install).
@@ -2783,6 +2842,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2783
2842
  };
2784
2843
  const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
2785
2844
 
2845
+ // Build the product-tree branches up front so each CVE's per-version CSAFPID
2846
+ // leaves can be bound into its product_status.known_affected (and the same
2847
+ // tree reused for product_tree below — buildCsafBranches is not re-run).
2848
+ const csafProductTree = buildCsafBranches(analyze.matched_cves || [], runOpts);
2849
+
2786
2850
  const cveVulns = analyze.matched_cves.map(c => {
2787
2851
  const isFixed = c.vex_status === 'fixed';
2788
2852
  const remediations = [{
@@ -2867,7 +2931,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2867
2931
  scores,
2868
2932
  threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: `Active exploitation confirmed${c.cisa_kev ? ' (CISA KEV)' : ''}.` }] : [],
2869
2933
  remediations,
2870
- product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
2934
+ // Bind this CVE's per-version CSAFPID leaves (from the product tree) into
2935
+ // known_affected, so a CSAF consumer correlating product_status back to
2936
+ // the branches tree resolves the real affected versions, not only the
2937
+ // opaque synthetic product. The leaves are parsed from affected_products
2938
+ // / affected_versions — VULNERABLE version ranges — so they belong ONLY
2939
+ // under known_affected. The VEX-fixed disposition keeps just the synthetic
2940
+ // target product under `fixed`; adding affected ranges there would
2941
+ // mislabel vulnerable versions as fixed releases.
2942
+ product_status: isFixed
2943
+ ? { fixed: [productId] }
2944
+ : { known_affected: [productId, ...(csafProductTree.cveProductIds[c.cve_id] || [])] }
2871
2945
  };
2872
2946
  // route by id shape.
2873
2947
  if (idIsCve) {
@@ -3054,7 +3128,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3054
3128
  // from catalog data. CSAF §3.1.5.1 makes branches[] strongly
3055
3129
  // recommended because NVD / ENISA / Red Hat dashboards render the
3056
3130
  // affected-product list off the branches tree, not full_product_names[].
3057
- const { branches } = buildCsafBranches(analyze.matched_cves || [], runOpts);
3131
+ const branches = csafProductTree.branches;
3058
3132
  const tree = { full_product_names: fullProductNames };
3059
3133
  if (branches.length > 0) tree.branches = branches;
3060
3134
  return tree;
@@ -3109,7 +3183,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3109
3183
  // (passing a null indicator skips the per-indicator evidence-locations
3110
3184
  // branch). Without any `locations`, GitHub Code Scanning silently DROPS
3111
3185
  // these results — the highest-severity result class would never surface.
3112
- const cveFallbackLocs = sarifLocationsForIndicator(playbook, null);
3113
3186
  const cveResults = analyze.matched_cves.map(c => {
3114
3187
  const result = {
3115
3188
  ruleId: `${rulePrefix}${c.cve_id}`,
@@ -3125,12 +3198,15 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3125
3198
  blast_radius_score: analyze.blast_radius_score,
3126
3199
  }),
3127
3200
  };
3128
- if (cveFallbackLocs) result.locations = cveFallbackLocs;
3201
+ // Always carry a location (physical when known, else a rule-scoped
3202
+ // logicalLocations fallback) so GitHub Code Scanning does not drop the
3203
+ // highest-severity result class.
3204
+ result.locations = sarifResultLocations(playbook, null, result.ruleId);
3129
3205
  return result;
3130
3206
  });
3131
3207
  const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
3132
3208
  const indicatorResults = indicatorHits.map(i => {
3133
- const locs = sarifLocationsForIndicator(playbook, i);
3209
+ const locs = sarifResultLocations(playbook, i, `${rulePrefix}${i.id}`);
3134
3210
  const result = {
3135
3211
  ruleId: `${rulePrefix}${i.id}`,
3136
3212
  level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note'),
@@ -3143,7 +3219,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3143
3219
  attack_ref: i.attack_ref,
3144
3220
  }),
3145
3221
  };
3146
- if (locs) result.locations = locs;
3222
+ result.locations = locs; // always present (physical or logical fallback)
3147
3223
  return result;
3148
3224
  });
3149
3225
  const gapResults = (analyze.framework_gap_mapping || []).map((g, idx) => ({
@@ -4061,6 +4137,36 @@ function canonicalStringify(v, _depth = 0) {
4061
4137
  return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k], _depth + 1)).join(',') + '}';
4062
4138
  }
4063
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
+
4064
4170
  /**
4065
4171
  * Pick the operator-meaningful fields out of the normalized submission
4066
4172
  * for hashing. captured_at, _signal_origins, _signal_origins_collisions,
@@ -4076,15 +4182,20 @@ function extractSubmissionForHash(sub) {
4076
4182
  // optional indicator binding) is what matters for "did the operator
4077
4183
  // submit the same evidence?".
4078
4184
  if (sub.artifacts && typeof sub.artifacts === 'object') {
4079
- pick.artifacts = {};
4185
+ const stripped = {};
4080
4186
  for (const [k, v] of Object.entries(sub.artifacts)) {
4081
4187
  if (v && typeof v === 'object') {
4082
4188
  const { captured_at, _captured_at, ...rest } = v;
4083
- pick.artifacts[k] = rest;
4189
+ stripped[k] = rest;
4084
4190
  } else {
4085
- pick.artifacts[k] = v;
4191
+ stripped[k] = v;
4086
4192
  }
4087
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);
4088
4199
  }
4089
4200
  if (sub.signal_overrides && typeof sub.signal_overrides === 'object') {
4090
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
  }
package/lib/source-osv.js CHANGED
@@ -95,11 +95,13 @@ const OSV_ID_PREFIXES = [
95
95
  * fields here MUST be ones the editorial review process can re-source.
96
96
  */
97
97
  const FIELD_DROPPED_WATCH = Object.freeze([
98
+ // Only fields the upstream feed actually populates can legitimately "drop"
99
+ // from populated -> null on a re-import. active_exploitation / ai_discovered /
100
+ // poc_available are EDITORIAL (curated by hand, never upstream-sourced), so
101
+ // the normalize step always nulls them — watching them flagged a field_dropped
102
+ // "regression" on every curated re-import. Restrict to upstream-sourced fields.
98
103
  "cvss_score",
99
104
  "cisa_kev_pending",
100
- "active_exploitation",
101
- "ai_discovered",
102
- "poc_available",
103
105
  ]);
104
106
 
105
107
  /**
@@ -942,7 +944,12 @@ async function buildDiff(ctx) {
942
944
  });
943
945
  }
944
946
  const errors = unreachable + normalizeErrors;
945
- const summary = `OSV fetched ${ids.length} id(s); ${diffs.length} new entry diff(s), ${unreachable} unreachable, ${normalizeErrors} normalize-rejected, ${ghsaOnlySkipped} ghsa_only_skipped.`;
947
+ // diffs holds BOTH _new_entry and field_dropped records — count them
948
+ // separately so the summary doesn't mislabel field-dropped regressions as
949
+ // brand-new entries.
950
+ const newCount = diffs.filter((d) => d.field === "_new_entry").length;
951
+ const droppedCount = diffs.filter((d) => d.variant === "field_dropped").length;
952
+ const summary = `OSV fetched ${ids.length} id(s); ${newCount} new entry diff(s), ${droppedCount} field-dropped regression(s), ${unreachable} unreachable, ${normalizeErrors} normalize-rejected, ${ghsaOnlySkipped} ghsa_only_skipped.`;
946
953
  return {
947
954
  status: errors === 0 ? "ok" : errors === ids.length ? "unreachable" : "partial",
948
955
  diffs,