@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.
- package/ARCHITECTURE.md +1 -1
- package/CHANGELOG.md +32 -0
- package/data/_indexes/_meta.json +10 -10
- package/data/_indexes/activity-feed.json +3 -3
- package/data/_indexes/catalog-summaries.json +3 -3
- package/data/_indexes/chains.json +11378 -0
- package/data/_indexes/frequency.json +3 -0
- package/data/attack-techniques.json +48 -4
- package/data/cve-catalog.json +2117 -2
- package/data/cwe-catalog.json +105 -1
- package/data/framework-control-gaps.json +120 -4
- package/data/global-frameworks.json +1 -0
- package/data/zeroday-lessons.json +1101 -1
- 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 +38 -3
- 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 +2 -2
- package/sbom.cdx.json +65 -65
- 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/auto-discovery.js
CHANGED
|
@@ -185,8 +185,15 @@ function readCachedJson(cacheDir, source, id) {
|
|
|
185
185
|
return parsed;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
function extractNvdMetrics(payload) {
|
|
189
|
-
|
|
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 =
|
package/lib/collectors/ai-api.js
CHANGED
|
@@ -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
|
-
//
|
|
114
|
-
|
|
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 (
|
|
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
|
-
|
|
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"];
|
package/lib/collectors/mcp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/lib/cross-ref-api.js
CHANGED
|
@@ -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]) =>
|
|
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
|
|
package/lib/framework-gap.js
CHANGED
|
@@ -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
|
}
|
package/lib/playbook-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
4189
|
+
stripped[k] = rest;
|
|
4160
4190
|
} else {
|
|
4161
|
-
|
|
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
|
|
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
|
}
|