@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.
- package/CHANGELOG.md +38 -0
- package/bin/exceptd.js +36 -1
- package/data/_indexes/_meta.json +3 -3
- package/data/global-frameworks.json +1 -0
- package/lib/auto-discovery.js +10 -3
- package/lib/collectors/ai-api.js +10 -0
- package/lib/collectors/citation-hygiene.js +46 -4
- package/lib/collectors/cred-stores.js +10 -1
- package/lib/collectors/mcp.js +7 -1
- package/lib/collectors/runtime.js +11 -0
- package/lib/collectors/secrets.js +13 -2
- package/lib/cross-ref-api.js +56 -2
- package/lib/framework-gap.js +22 -1
- package/lib/playbook-runner.js +127 -16
- package/lib/prefetch.js +61 -15
- package/lib/refresh-external.js +16 -2
- package/lib/scoring.js +22 -3
- package/lib/source-ghsa.js +12 -4
- package/lib/source-osv.js +11 -4
- package/manifest.json +53 -53
- package/package.json +1 -1
- package/sbom.cdx.json +54 -54
- package/scripts/check-codebase-patterns.js +11 -0
- package/scripts/check-test-subjects.js +16 -1
- package/scripts/check-version-bump.js +35 -5
- package/sources/validators/cve-validator.js +5 -1
package/lib/playbook-runner.js
CHANGED
|
@@ -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, ...
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4189
|
+
stripped[k] = rest;
|
|
4084
4190
|
} else {
|
|
4085
|
-
|
|
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
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
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
|
|
236
|
-
// feed
|
|
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
|
|
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
|
|
package/lib/refresh-external.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/source-ghsa.js
CHANGED
|
@@ -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; ${
|
|
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
|
-
|
|
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,
|