@blamejs/exceptd-skills 0.12.13 → 0.12.16
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 +217 -0
- package/bin/exceptd.js +522 -27
- package/data/_indexes/_meta.json +45 -45
- package/data/_indexes/activity-feed.json +4 -4
- package/data/_indexes/catalog-summaries.json +29 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +319 -76
- package/data/cve-catalog.json +516 -476
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/playbooks/ai-api.json +3 -1
- package/data/playbooks/containers.json +11 -3
- package/data/playbooks/cred-stores.json +3 -1
- package/data/playbooks/crypto-codebase.json +11 -11
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/hardening.json +3 -1
- package/data/playbooks/kernel.json +3 -1
- package/data/playbooks/library-author.json +21 -10
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +3 -1
- package/data/playbooks/sbom.json +2 -2
- package/data/playbooks/secrets.json +3 -1
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +57 -35
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +33 -14
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +742 -78
- package/lib/prefetch.js +30 -8
- package/lib/refresh-external.js +40 -22
- package/lib/refresh-network.js +233 -17
- package/lib/scoring.js +191 -18
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +56 -18
- package/lib/validate-indexes.js +88 -37
- package/lib/validate-playbooks.js +46 -0
- package/lib/verify.js +72 -0
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +150 -17
- package/package.json +3 -1
- package/sbom.cdx.json +7 -7
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +83 -39
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +141 -9
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
package/lib/scoring.js
CHANGED
|
@@ -3,6 +3,40 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* RWEP — Real-World Exploit Priority scoring engine
|
|
5
5
|
* Supplements CVSS with exploit availability, active exploitation, and operational constraints.
|
|
6
|
+
*
|
|
7
|
+
* ----------------------------------------------------------------------------
|
|
8
|
+
* `rwep_factors` dual-semantics (audit J F2)
|
|
9
|
+
* ----------------------------------------------------------------------------
|
|
10
|
+
* Catalog entries (data/cve-catalog.json) store `rwep_factors` as an object
|
|
11
|
+
* whose values are POST-WEIGHT CONTRIBUTIONS for boolean / ladder factors
|
|
12
|
+
* but the RAW BLAST RADIUS for `blast_radius`. The two shapes coexist because
|
|
13
|
+
* each surface has different requirements:
|
|
14
|
+
*
|
|
15
|
+
* cisa_kev: 0 OR +25 (post-weight contribution)
|
|
16
|
+
* poc_available: 0 OR +20 (post-weight contribution)
|
|
17
|
+
* ai_factor: 0 OR +15 (post-weight contribution)
|
|
18
|
+
* active_exploitation: 0 / 10 / 5 / 20 (post-weight contribution from ladder)
|
|
19
|
+
* blast_radius: 0..30 RAW (intentionally NOT post-weight —
|
|
20
|
+
* mirrors the weight ceiling so it
|
|
21
|
+
* reads as raw blast magnitude)
|
|
22
|
+
* patch_available: 0 OR -15 (post-weight contribution)
|
|
23
|
+
* live_patch_available: 0 OR -10 (post-weight contribution)
|
|
24
|
+
* reboot_required: 0 OR +5 (post-weight contribution)
|
|
25
|
+
*
|
|
26
|
+
* Operator-facing implication: summing `Object.values(rwep_factors)` produces
|
|
27
|
+
* the stored `rwep_score` for catalog entries because the blast weight is 30
|
|
28
|
+
* (matches the raw cap). This dual-shape is intentional but easy to misuse;
|
|
29
|
+
* direct boolean inputs should go through `scoreCustom()` instead.
|
|
30
|
+
*
|
|
31
|
+
* scoreCustom() input shape is DIFFERENT — it accepts BOOLEAN factors plus
|
|
32
|
+
* a numeric blast_radius and a string active_exploitation ladder value.
|
|
33
|
+
* `deriveRwepFromFactors()` is the shape-detecting bridge: if values look
|
|
34
|
+
* numeric (post-weighted), it sums; if values look boolean / string-ladder,
|
|
35
|
+
* it routes through scoreCustom.
|
|
36
|
+
*
|
|
37
|
+
* The semantic ambiguity is grandfathered. A clean rename (raw_factors vs
|
|
38
|
+
* weighted_contributions) is a minor-bump change and is deferred.
|
|
39
|
+
* ----------------------------------------------------------------------------
|
|
6
40
|
*/
|
|
7
41
|
|
|
8
42
|
const CVE_SCHEMA_REQUIRED = [
|
|
@@ -28,6 +62,29 @@ const RWEP_WEIGHTS = {
|
|
|
28
62
|
reboot_required: 5
|
|
29
63
|
};
|
|
30
64
|
|
|
65
|
+
// audit J F4: active_exploitation ladder. Aligned with playbook-runner's
|
|
66
|
+
// _activeExploitationLadder so the catalog scorer and the runtime evaluator
|
|
67
|
+
// produce identical results for the same string value. 'unknown' contributes
|
|
68
|
+
// a quarter of the confirmed weight (5 points) — operationally "we have not
|
|
69
|
+
// confirmed, but absence of evidence is not evidence of absence; do not
|
|
70
|
+
// score zero on a fresh CVE that hasn't been triaged yet".
|
|
71
|
+
const ACTIVE_EXPLOITATION_LADDER = {
|
|
72
|
+
confirmed: 1.0,
|
|
73
|
+
suspected: 0.5,
|
|
74
|
+
unknown: 0.25,
|
|
75
|
+
none: 0,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// The canonical set of factor keys scoreCustom recognises. Used by
|
|
79
|
+
// validateFactors to flag unknown keys (audit J F8).
|
|
80
|
+
const RECOGNISED_FACTOR_KEYS = new Set([
|
|
81
|
+
'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
82
|
+
'active_exploitation', 'blast_radius', 'patch_available',
|
|
83
|
+
'live_patch_available', 'reboot_required',
|
|
84
|
+
// accepted alias for the catalog field name
|
|
85
|
+
'patch_required_reboot',
|
|
86
|
+
]);
|
|
87
|
+
|
|
31
88
|
function score(cveId, catalog) {
|
|
32
89
|
const entry = catalog[cveId];
|
|
33
90
|
if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
|
|
@@ -68,13 +125,29 @@ function validateFactors(factors) {
|
|
|
68
125
|
} else if (!aeAllowed.includes(factors.active_exploitation)) {
|
|
69
126
|
warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
|
|
70
127
|
}
|
|
128
|
+
// audit J F6: NaN diagnostics. The prior message read "expected number,
|
|
129
|
+
// got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
|
|
130
|
+
// NaN === 'number'`. Number.isFinite catches NaN + Infinity + -Infinity
|
|
131
|
+
// and emits a useful message.
|
|
71
132
|
if (factors.blast_radius === undefined || factors.blast_radius === null) {
|
|
72
133
|
warnings.push('blast_radius: missing (treated as 0)');
|
|
73
|
-
} else if (typeof factors.blast_radius !== 'number'
|
|
134
|
+
} else if (typeof factors.blast_radius !== 'number') {
|
|
74
135
|
warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
|
|
136
|
+
} else if (Number.isNaN(factors.blast_radius)) {
|
|
137
|
+
warnings.push('blast_radius: NaN is not a valid numeric value (treated as 0)');
|
|
138
|
+
} else if (!Number.isFinite(factors.blast_radius)) {
|
|
139
|
+
warnings.push(`blast_radius: ${factors.blast_radius > 0 ? 'Infinity' : '-Infinity'} is not a finite numeric value (treated as 0)`);
|
|
75
140
|
} else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
|
|
76
141
|
warnings.push(`blast_radius: ${factors.blast_radius} out of expected range [0, 30] (clamped to weight ceiling, but the value usually indicates a unit-of-measure mistake)`);
|
|
77
142
|
}
|
|
143
|
+
// audit J F8: surface unknown factor keys so a typo'd answer file
|
|
144
|
+
// (`patch_avilable`, `cisa-kev`, etc.) doesn't silently default to false
|
|
145
|
+
// with no diagnostic.
|
|
146
|
+
for (const k of Object.keys(factors)) {
|
|
147
|
+
if (!RECOGNISED_FACTOR_KEYS.has(k)) {
|
|
148
|
+
warnings.push(`unknown factor: ${k} (ignored — not in the recognised key set)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
78
151
|
return warnings;
|
|
79
152
|
}
|
|
80
153
|
|
|
@@ -99,31 +172,96 @@ function scoreCustom(factors, opts) {
|
|
|
99
172
|
blast_radius = 0,
|
|
100
173
|
patch_available = false,
|
|
101
174
|
live_patch_available = false,
|
|
102
|
-
reboot_required = false
|
|
175
|
+
reboot_required = false,
|
|
176
|
+
// v0.12.15 (audit J F9): the CVE catalog field is `patch_required_reboot`
|
|
177
|
+
// but scoreCustom historically expected `reboot_required`. validate()
|
|
178
|
+
// already aliases at the call site; accept either spelling here so a
|
|
179
|
+
// direct caller passing the catalog entry doesn't silently lose the
|
|
180
|
+
// reboot factor.
|
|
181
|
+
patch_required_reboot,
|
|
103
182
|
} = factors || {};
|
|
183
|
+
const rebootFactor = (reboot_required === true) || (patch_required_reboot === true);
|
|
104
184
|
|
|
105
185
|
let score = 0;
|
|
106
186
|
score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
|
|
107
187
|
score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
|
|
108
188
|
score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
189
|
+
// audit J F4 + F16: active_exploitation goes through the ladder rather
|
|
190
|
+
// than two hand-written branches with `Math.floor(weight/2)`. The floor
|
|
191
|
+
// was a no-op for even weights (20/2 = 10) but would have silently
|
|
192
|
+
// truncated to asymmetric results if a future operator bumped the
|
|
193
|
+
// weight to 21. The ladder + multiplication preserves the contribution
|
|
194
|
+
// exactly, including the new `unknown → 0.25 × weight = 5` mapping that
|
|
195
|
+
// aligns the catalog scorer with playbook-runner._activeExploitationLadder.
|
|
196
|
+
const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
|
|
197
|
+
score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
|
|
198
|
+
// v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
|
|
199
|
+
// NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
|
|
200
|
+
// check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
|
|
201
|
+
// which propagates NaN through the final clamp, defeating the [0,100]
|
|
202
|
+
// contract. Number.isFinite + Number() coercion catches all four classes:
|
|
203
|
+
// NaN, Infinity, undefined, stringified-number.
|
|
204
|
+
const brRaw = Number.isFinite(Number(blast_radius)) ? Number(blast_radius) : 0;
|
|
205
|
+
const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, brRaw));
|
|
115
206
|
score += brClamped;
|
|
116
207
|
score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
|
|
117
208
|
score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
|
|
118
|
-
score +=
|
|
209
|
+
score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
|
|
210
|
+
|
|
211
|
+
// audit J F10: keep the pre-clamp value so collectWarnings consumers can
|
|
212
|
+
// see deduction magnitude (e.g. a -25 raw score collapsed to 0 hides the
|
|
213
|
+
// fact that the entry had three mitigating factors).
|
|
214
|
+
const rawUnclamped = score;
|
|
119
215
|
|
|
120
|
-
|
|
216
|
+
// v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
|
|
217
|
+
// NaN production above (negative weight + Infinity + math edge case).
|
|
218
|
+
const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
|
|
121
219
|
if (opts && opts.collectWarnings) {
|
|
122
|
-
return {
|
|
220
|
+
return {
|
|
221
|
+
score: clamped,
|
|
222
|
+
_rwep_raw_unclamped: Number.isFinite(rawUnclamped) ? rawUnclamped : null,
|
|
223
|
+
_scoring_warnings: validateFactors(factors),
|
|
224
|
+
};
|
|
123
225
|
}
|
|
124
226
|
return clamped;
|
|
125
227
|
}
|
|
126
228
|
|
|
229
|
+
/**
|
|
230
|
+
* audit J F3 + audit M P1-C bridge: derive an RWEP score from a
|
|
231
|
+
* `rwep_factors` object regardless of which shape it uses.
|
|
232
|
+
*
|
|
233
|
+
* - SHAPE A (boolean / string-ladder): values are booleans + an
|
|
234
|
+
* active_exploitation string + a numeric blast_radius. Route through
|
|
235
|
+
* scoreCustom() — the canonical formula.
|
|
236
|
+
* - SHAPE B (catalog post-weight): values are numeric contributions
|
|
237
|
+
* (0 / ±N) plus a numeric blast_radius. Sum the numeric values and
|
|
238
|
+
* clamp to [0, 100]. This is how catalog `rwep_factors` are stored.
|
|
239
|
+
*
|
|
240
|
+
* Heuristic: if every value is a number, treat as Shape B (sum). If any
|
|
241
|
+
* value is boolean or a recognised ladder string, treat as Shape A
|
|
242
|
+
* (scoreCustom). This lets the curation apply-path and the auto-discovery
|
|
243
|
+
* builder share one canonical derivation that handles either operator
|
|
244
|
+
* input style without duplicating the scoring formula.
|
|
245
|
+
*/
|
|
246
|
+
function deriveRwepFromFactors(factors) {
|
|
247
|
+
if (!factors || typeof factors !== 'object') return 0;
|
|
248
|
+
const values = Object.values(factors);
|
|
249
|
+
if (values.length === 0) return 0;
|
|
250
|
+
const aeAllowed = new Set(['none', 'unknown', 'suspected', 'confirmed']);
|
|
251
|
+
const hasBooleanOrLadder = values.some(
|
|
252
|
+
(v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v)),
|
|
253
|
+
);
|
|
254
|
+
if (hasBooleanOrLadder) {
|
|
255
|
+
return scoreCustom(factors);
|
|
256
|
+
}
|
|
257
|
+
// Shape B: catalog post-weight. Sum + clamp.
|
|
258
|
+
let sum = 0;
|
|
259
|
+
for (const v of values) {
|
|
260
|
+
if (typeof v === 'number' && Number.isFinite(v)) sum += v;
|
|
261
|
+
}
|
|
262
|
+
return Math.max(0, Math.min(100, sum));
|
|
263
|
+
}
|
|
264
|
+
|
|
127
265
|
function timeline(rwepScore) {
|
|
128
266
|
if (rwepScore >= 90) return { hours: 4, label: 'Immediate — live patch or isolate within 4 hours' };
|
|
129
267
|
if (rwepScore >= 75) return { hours: 24, label: 'Urgent — patch or compensating controls within 24 hours' };
|
|
@@ -133,17 +271,36 @@ function timeline(rwepScore) {
|
|
|
133
271
|
return { hours: null, label: 'Low — next scheduled maintenance' };
|
|
134
272
|
}
|
|
135
273
|
|
|
136
|
-
function compare(cveId, catalog) {
|
|
274
|
+
function compare(cveId, catalog, opts) {
|
|
137
275
|
const entry = catalog[cveId];
|
|
138
276
|
if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
|
|
139
277
|
|
|
140
|
-
|
|
278
|
+
// audit J F11: `--recompute` ignores the stored rwep_score and forces a
|
|
279
|
+
// fresh computation from rwep_factors. Useful for catching catalog drift
|
|
280
|
+
// (stored score grew stale relative to current weights) and for auditing
|
|
281
|
+
// the divergence between stored vs. formula-derived scores.
|
|
282
|
+
const recompute = !!(opts && opts.recompute);
|
|
283
|
+
let rwep;
|
|
284
|
+
if (recompute) {
|
|
285
|
+
const factors = entry.rwep_factors || {};
|
|
286
|
+
// The catalog's rwep_factors shape is "post-weight" (Shape B). Route
|
|
287
|
+
// through the shape-detecting helper so a catalog whose factors were
|
|
288
|
+
// hand-edited in either shape still produces a usable score.
|
|
289
|
+
rwep = deriveRwepFromFactors(factors);
|
|
290
|
+
} else {
|
|
291
|
+
rwep = entry.rwep_score;
|
|
292
|
+
}
|
|
141
293
|
const cvss = entry.cvss_score;
|
|
142
294
|
const cvssEquivalent = cvss * 10;
|
|
143
295
|
const delta = rwep - cvssEquivalent;
|
|
144
296
|
|
|
297
|
+
// audit J F15: narrow the "broadly aligned" band from ±20 to ±10. The old
|
|
298
|
+
// ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
|
|
299
|
+
// where the operator-facing point is precisely that the CVSS-calibrated
|
|
300
|
+
// SLA is insufficient. ±10 is the tightest classifier that still treats
|
|
301
|
+
// ordinary CVSS rounding noise as alignment.
|
|
145
302
|
let explanation = '';
|
|
146
|
-
if (delta >
|
|
303
|
+
if (delta > 10) {
|
|
147
304
|
explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
|
|
148
305
|
const driving = [];
|
|
149
306
|
if (entry.cisa_kev) driving.push('CISA KEV (+25)');
|
|
@@ -153,7 +310,7 @@ function compare(cveId, catalog) {
|
|
|
153
310
|
if (entry.patch_required_reboot && !entry.live_patch_available) driving.push('reboot required (+5)');
|
|
154
311
|
explanation += driving.join(', ');
|
|
155
312
|
explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
|
|
156
|
-
} else if (delta < -
|
|
313
|
+
} else if (delta < -10) {
|
|
157
314
|
explanation = `RWEP lower than CVSS equivalent. Mitigating factors: `;
|
|
158
315
|
const mitigating = [];
|
|
159
316
|
if (entry.patch_available) mitigating.push('patch available (-15)');
|
|
@@ -165,15 +322,20 @@ function compare(cveId, catalog) {
|
|
|
165
322
|
explanation = 'CVSS and RWEP are broadly aligned for this CVE.';
|
|
166
323
|
}
|
|
167
324
|
|
|
168
|
-
|
|
325
|
+
const out = {
|
|
169
326
|
cve_id: cveId,
|
|
170
327
|
cvss: cvss,
|
|
171
328
|
rwep: rwep,
|
|
172
329
|
cvss_framework_sla: timeline(cvssEquivalent),
|
|
173
330
|
rwep_actual_sla: timeline(rwep),
|
|
174
331
|
delta,
|
|
175
|
-
explanation
|
|
332
|
+
explanation,
|
|
176
333
|
};
|
|
334
|
+
if (recompute) {
|
|
335
|
+
out.stored_rwep_score = entry.rwep_score;
|
|
336
|
+
out.recomputed = true;
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
177
339
|
}
|
|
178
340
|
|
|
179
341
|
function validate(catalog) {
|
|
@@ -209,4 +371,15 @@ function validate(catalog) {
|
|
|
209
371
|
return errors;
|
|
210
372
|
}
|
|
211
373
|
|
|
212
|
-
module.exports = {
|
|
374
|
+
module.exports = {
|
|
375
|
+
score,
|
|
376
|
+
scoreCustom,
|
|
377
|
+
timeline,
|
|
378
|
+
compare,
|
|
379
|
+
validate,
|
|
380
|
+
validateFactors,
|
|
381
|
+
deriveRwepFromFactors,
|
|
382
|
+
RWEP_WEIGHTS,
|
|
383
|
+
ACTIVE_EXPLOITATION_LADDER,
|
|
384
|
+
RECOGNISED_FACTOR_KEYS,
|
|
385
|
+
};
|
package/lib/source-ghsa.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
const https = require("https");
|
|
28
28
|
const fs = require("fs");
|
|
29
|
+
const { withRetry } = require("../vendor/blamejs/retry.js");
|
|
29
30
|
|
|
30
31
|
const GHSA_HOST = "api.github.com";
|
|
31
32
|
const GHSA_PATH = "/advisories?per_page=50&type=reviewed&sort=published&direction=desc";
|
|
@@ -33,40 +34,93 @@ const REQUEST_TIMEOUT_MS = 10000;
|
|
|
33
34
|
const USER_AGENT = "exceptd-security/source-ghsa (+https://exceptd.com)";
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
*
|
|
37
|
+
* Field-dropped watch set — fields the buildDiff regression-detector
|
|
38
|
+
* watches when the upstream still has an entry but a previously-populated
|
|
39
|
+
* local value has gone null. Mirrors lib/source-osv.js. Finding 9.
|
|
40
|
+
*/
|
|
41
|
+
const FIELD_DROPPED_WATCH = Object.freeze([
|
|
42
|
+
"cvss_score",
|
|
43
|
+
"cisa_kev_pending",
|
|
44
|
+
"active_exploitation",
|
|
45
|
+
"ai_discovered",
|
|
46
|
+
"poc_available",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Return true when the runtime context requests air-gap mode. Sources MUST
|
|
51
|
+
* refuse network calls when this is set — fall through to fixture or return
|
|
52
|
+
* a structured `air-gap: no fixture available` error so the operator sees
|
|
53
|
+
* an explicit refusal, not a silent network attempt. Mirrors source-osv.
|
|
54
|
+
*/
|
|
55
|
+
function isAirGap(opts) {
|
|
56
|
+
if (opts && opts.airGap) return true;
|
|
57
|
+
if (process.env.EXCEPTD_AIR_GAP === "1") return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read EXCEPTD_GHSA_FIXTURE and return a structured envelope. Finding 5:
|
|
63
|
+
* mirror the OSV-source convention so a fixture file containing `null`,
|
|
64
|
+
* a number, or a string at its root doesn't slip through as an empty
|
|
65
|
+
* advisories array — the strict catalog validator would later swallow the
|
|
66
|
+
* resulting drift as "no advisories returned" instead of surfacing it as
|
|
67
|
+
* a fixture configuration error. Returns:
|
|
37
68
|
*
|
|
38
|
-
*
|
|
39
|
-
* { ok: true,
|
|
40
|
-
* { ok: false, error, source: "offline" }
|
|
69
|
+
* null when env var is unset
|
|
70
|
+
* { ok: true, advisories: [...], source } on success
|
|
71
|
+
* { ok: false, error, source: "offline" } on any failure
|
|
41
72
|
*/
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
73
|
+
function readFixture() {
|
|
74
|
+
const fp = process.env.EXCEPTD_GHSA_FIXTURE;
|
|
75
|
+
if (!fp) return null;
|
|
76
|
+
let raw;
|
|
77
|
+
try {
|
|
78
|
+
raw = fs.readFileSync(fp, "utf8");
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
|
|
50
87
|
}
|
|
88
|
+
if (parsed == null || typeof parsed !== "object") {
|
|
89
|
+
return { ok: false, error: `fixture: invalid root shape (got ${typeof parsed}); expected GHSA advisory object or array`, source: "offline" };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, advisories: Array.isArray(parsed) ? parsed : [parsed], source: "fixture" };
|
|
92
|
+
}
|
|
51
93
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
headers.Authorization = `Bearer ${token || process.env.GITHUB_TOKEN}`;
|
|
60
|
-
}
|
|
94
|
+
/**
|
|
95
|
+
* One HTTPS GET against api.github.com. Throws on retryable conditions so
|
|
96
|
+
* withRetry's default classifier (HTTP 408/425/429/5xx + ECONNRESET et al)
|
|
97
|
+
* picks them up; resolves to a structured envelope on permanent conditions.
|
|
98
|
+
*/
|
|
99
|
+
function ghsaRequestOnce({ path, headers, timeoutMs }) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
61
101
|
const req = https.get({
|
|
62
102
|
host: GHSA_HOST,
|
|
63
103
|
path,
|
|
64
104
|
headers,
|
|
65
105
|
timeout: timeoutMs,
|
|
66
106
|
}, (res) => {
|
|
67
|
-
|
|
107
|
+
const status = res.statusCode;
|
|
108
|
+
if (status === 429 || status === 503 ||
|
|
109
|
+
(status >= 500 && status <= 599) ||
|
|
110
|
+
status === 408 || status === 425) {
|
|
68
111
|
res.resume();
|
|
69
|
-
|
|
112
|
+
const err = new Error(`GHSA returned HTTP ${status}`);
|
|
113
|
+
err.statusCode = status;
|
|
114
|
+
const ra = res.headers["retry-after"];
|
|
115
|
+
if (ra) {
|
|
116
|
+
const secs = parseInt(ra, 10);
|
|
117
|
+
if (Number.isFinite(secs)) err.retryAfterMs = secs * 1000;
|
|
118
|
+
}
|
|
119
|
+
return reject(err);
|
|
120
|
+
}
|
|
121
|
+
if (status !== 200) {
|
|
122
|
+
res.resume();
|
|
123
|
+
return resolve({ ok: false, error: `GHSA returned HTTP ${status}`, source: "offline" });
|
|
70
124
|
}
|
|
71
125
|
const chunks = [];
|
|
72
126
|
res.on("data", (c) => chunks.push(c));
|
|
@@ -88,11 +142,60 @@ async function fetchAdvisories({ timeoutMs = REQUEST_TIMEOUT_MS, path = GHSA_PAT
|
|
|
88
142
|
}
|
|
89
143
|
});
|
|
90
144
|
});
|
|
91
|
-
req.on("timeout", () =>
|
|
92
|
-
|
|
145
|
+
req.on("timeout", () => {
|
|
146
|
+
const err = new Error("timeout");
|
|
147
|
+
err.code = "ETIMEDOUT";
|
|
148
|
+
req.destroy(err);
|
|
149
|
+
});
|
|
150
|
+
req.on("error", (e) => {
|
|
151
|
+
if (e && e.code && /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|EAGAIN|ENOTFOUND|ENETUNREACH)$/.test(e.code)) {
|
|
152
|
+
return reject(e);
|
|
153
|
+
}
|
|
154
|
+
resolve({ ok: false, error: e.message, source: "offline" });
|
|
155
|
+
});
|
|
93
156
|
});
|
|
94
157
|
}
|
|
95
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Fetch a page of advisories (default: latest 50). Wraps the underlying
|
|
161
|
+
* HTTPS request in withRetry so transient 429/503/5xx + net errors back off
|
|
162
|
+
* automatically.
|
|
163
|
+
*
|
|
164
|
+
* Returns:
|
|
165
|
+
* { ok: true, advisories: [...], source: "github-api" | "fixture", rate_limit?: { remaining, reset } }
|
|
166
|
+
* { ok: false, error, source: "offline" }
|
|
167
|
+
*/
|
|
168
|
+
async function fetchAdvisories({ timeoutMs = REQUEST_TIMEOUT_MS, path = GHSA_PATH, token = null, airGap = false } = {}) {
|
|
169
|
+
const fixture = readFixture();
|
|
170
|
+
if (fixture) return fixture;
|
|
171
|
+
// Finding 7: air-gap hard-refuses network when no fixture is configured.
|
|
172
|
+
if (isAirGap({ airGap })) {
|
|
173
|
+
return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_GHSA_FIXTURE)", source: "offline" };
|
|
174
|
+
}
|
|
175
|
+
const headers = {
|
|
176
|
+
"Accept": "application/vnd.github+json",
|
|
177
|
+
"User-Agent": USER_AGENT,
|
|
178
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
179
|
+
};
|
|
180
|
+
if (token || process.env.GITHUB_TOKEN) {
|
|
181
|
+
headers.Authorization = `Bearer ${token || process.env.GITHUB_TOKEN}`;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
return await withRetry(() => ghsaRequestOnce({ path, headers, timeoutMs }), {
|
|
185
|
+
maxAttempts: 3,
|
|
186
|
+
baseDelayMs: 100,
|
|
187
|
+
maxDelayMs: 2000,
|
|
188
|
+
jitterFactor: 0.5,
|
|
189
|
+
});
|
|
190
|
+
} catch (e) {
|
|
191
|
+
const status = typeof e?.statusCode === "number" ? e.statusCode : null;
|
|
192
|
+
const error = status
|
|
193
|
+
? `GHSA returned HTTP ${status}`
|
|
194
|
+
: `GHSA request failed: ${e.message || e}`;
|
|
195
|
+
return { ok: false, error, status, source: "offline" };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
96
199
|
/**
|
|
97
200
|
* Fetch a single advisory by ID — accepts CVE-* or GHSA-* identifiers.
|
|
98
201
|
*
|
|
@@ -103,12 +206,18 @@ async function fetchAdvisoryById(id, opts = {}) {
|
|
|
103
206
|
if (!id || typeof id !== "string") {
|
|
104
207
|
return { ok: false, error: "id is required (CVE-* or GHSA-*)", source: "offline" };
|
|
105
208
|
}
|
|
209
|
+
// Finding 8: trim whitespace at the entry seam.
|
|
210
|
+
id = id.trim();
|
|
211
|
+
if (!id) {
|
|
212
|
+
return { ok: false, error: "id is required (CVE-* or GHSA-*)", source: "offline" };
|
|
213
|
+
}
|
|
106
214
|
if (process.env.EXCEPTD_GHSA_FIXTURE) {
|
|
107
215
|
const r = await fetchAdvisories(opts);
|
|
108
216
|
if (!r.ok) return r;
|
|
217
|
+
const want = id.toUpperCase();
|
|
109
218
|
const match = r.advisories.find(a =>
|
|
110
|
-
(a.ghsa_id && a.ghsa_id.toUpperCase() ===
|
|
111
|
-
(a.cve_id && a.cve_id.toUpperCase() ===
|
|
219
|
+
(a.ghsa_id && String(a.ghsa_id).toUpperCase() === want) ||
|
|
220
|
+
(a.cve_id && String(a.cve_id).toUpperCase() === want)
|
|
112
221
|
);
|
|
113
222
|
if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
|
|
114
223
|
return { ok: true, advisories: [match], source: "fixture" };
|
|
@@ -128,6 +237,23 @@ async function fetchAdvisoryById(id, opts = {}) {
|
|
|
128
237
|
return { ok: false, error: `unrecognized id format: ${id}. Expected one of: CVE-YYYY-NNNN, GHSA-* (routed through source-ghsa); MAL-* / SNYK-* / RUSTSEC-* / USN-* / PYSEC-* / GO-* / MGASA-* / UVI- (routed through source-osv).`, source: "offline" };
|
|
129
238
|
}
|
|
130
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Validate + slice a published_at timestamp. Findings 2 + 17:
|
|
242
|
+
* - typeof guard so non-string inputs (number, object, undefined) become
|
|
243
|
+
* null instead of throwing on .slice().
|
|
244
|
+
* - ISO-prefix + year sanity bound so garbage timestamps don't pollute
|
|
245
|
+
* downstream surfaces.
|
|
246
|
+
*/
|
|
247
|
+
function safeDateSlice(value) {
|
|
248
|
+
if (typeof value !== "string") return null;
|
|
249
|
+
const head = value.slice(0, 10);
|
|
250
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(head)) return null;
|
|
251
|
+
const year = parseInt(head.slice(0, 4), 10);
|
|
252
|
+
const now = new Date().getUTCFullYear();
|
|
253
|
+
if (!Number.isFinite(year) || year < 1990 || year > now + 1) return null;
|
|
254
|
+
return head;
|
|
255
|
+
}
|
|
256
|
+
|
|
131
257
|
/**
|
|
132
258
|
* Normalize a GHSA advisory object to the exceptd catalog draft shape.
|
|
133
259
|
* Fields the GHSA carries authoritatively: cve_id, ghsa_id, summary,
|
|
@@ -146,7 +272,9 @@ function normalizeAdvisory(adv) {
|
|
|
146
272
|
const ecosystems = new Set();
|
|
147
273
|
const affected = [];
|
|
148
274
|
const ecosystemPackages = [];
|
|
149
|
-
|
|
275
|
+
// Finding 3: vulnerabilities may not be an array — guard before iterating.
|
|
276
|
+
const vulnList = Array.isArray(adv.vulnerabilities) ? adv.vulnerabilities : [];
|
|
277
|
+
for (const v of vulnList) {
|
|
150
278
|
if (v?.package?.ecosystem) ecosystems.add(v.package.ecosystem);
|
|
151
279
|
if (v?.package?.name) {
|
|
152
280
|
ecosystemPackages.push(`${v.package.ecosystem || "?"}:${v.package.name}`);
|
|
@@ -156,7 +284,14 @@ function normalizeAdvisory(adv) {
|
|
|
156
284
|
}
|
|
157
285
|
}
|
|
158
286
|
|
|
159
|
-
|
|
287
|
+
// Finding 4: cvss.score may arrive as a string ("9.8") rather than a
|
|
288
|
+
// number. Number-coerce + finite-check so the catalog field stays
|
|
289
|
+
// numeric across upstream shape drift.
|
|
290
|
+
let cvssScore = null;
|
|
291
|
+
if (adv.cvss != null && adv.cvss.score !== undefined && adv.cvss.score !== null) {
|
|
292
|
+
const n = Number(adv.cvss.score);
|
|
293
|
+
cvssScore = Number.isFinite(n) ? n : null;
|
|
294
|
+
}
|
|
160
295
|
const cvssVector = adv.cvss?.vector_string || null;
|
|
161
296
|
const severity = (adv.severity || "").toLowerCase();
|
|
162
297
|
// Derive a coarse type from package ecosystem when nothing better available.
|
|
@@ -166,6 +301,13 @@ function normalizeAdvisory(adv) {
|
|
|
166
301
|
: ecosystems.has("rubygems") ? "supply-chain-gem"
|
|
167
302
|
: "supply-chain-other";
|
|
168
303
|
|
|
304
|
+
// Finding 2 + 17: type-safe + format-validated published_at slicing.
|
|
305
|
+
const publishedDate = safeDateSlice(adv.published_at);
|
|
306
|
+
|
|
307
|
+
// Finding 20: references may not be an array — guard the spread before
|
|
308
|
+
// it silently truncates to an empty list.
|
|
309
|
+
const refList = Array.isArray(adv.references) ? adv.references : [];
|
|
310
|
+
|
|
169
311
|
return {
|
|
170
312
|
[adv.cve_id]: {
|
|
171
313
|
name: adv.summary || adv.cve_id,
|
|
@@ -205,7 +347,7 @@ function normalizeAdvisory(adv) {
|
|
|
205
347
|
verification_sources: [
|
|
206
348
|
...(adv.html_url ? [adv.html_url] : []),
|
|
207
349
|
...(adv.cve_id ? [`https://nvd.nist.gov/vuln/detail/${adv.cve_id}`] : []),
|
|
208
|
-
...
|
|
350
|
+
...refList.slice(0, 10),
|
|
209
351
|
],
|
|
210
352
|
vendor_advisories: [
|
|
211
353
|
{
|
|
@@ -213,7 +355,7 @@ function normalizeAdvisory(adv) {
|
|
|
213
355
|
advisory_id: adv.ghsa_id || null,
|
|
214
356
|
url: adv.html_url || `https://github.com/advisories?query=${encodeURIComponent(adv.cve_id)}`,
|
|
215
357
|
severity: severity || null,
|
|
216
|
-
published_date:
|
|
358
|
+
published_date: publishedDate,
|
|
217
359
|
},
|
|
218
360
|
],
|
|
219
361
|
iocs: null,
|
|
@@ -231,19 +373,51 @@ function normalizeAdvisory(adv) {
|
|
|
231
373
|
* Build a refresh diff for the existing refresh-external orchestrator.
|
|
232
374
|
* Compares the latest 50 advisories' CVE IDs against the local catalog;
|
|
233
375
|
* any CVE ID not in the catalog becomes an "add" diff.
|
|
376
|
+
*
|
|
377
|
+
* Finding 9: when the advisory is already in the catalog but a watched
|
|
378
|
+
* field has dropped from populated -> null, surface a `field_dropped`
|
|
379
|
+
* diff so curators don't silently lose signal.
|
|
380
|
+
*
|
|
381
|
+
* Finding 18: count + surface GHSA-only advisories (no CVE id) that were
|
|
382
|
+
* skipped, so the summary explains why N upstream advisories produced
|
|
383
|
+
* fewer than N diffs.
|
|
234
384
|
*/
|
|
235
385
|
async function buildDiff(ctx) {
|
|
236
|
-
const result = await fetchAdvisories({});
|
|
386
|
+
const result = await fetchAdvisories({ airGap: ctx?.airGap });
|
|
237
387
|
if (!result.ok) {
|
|
238
388
|
return { status: "unreachable", diffs: [], errors: 1, summary: `GHSA fetch failed: ${result.error}` };
|
|
239
389
|
}
|
|
240
|
-
const
|
|
390
|
+
const cveCatalog = ctx.cveCatalog || {};
|
|
391
|
+
const existing = new Set(Object.keys(cveCatalog).filter(k => /^CVE-/.test(k)));
|
|
241
392
|
const diffs = [];
|
|
393
|
+
let ghsaOnlySkipped = 0;
|
|
242
394
|
for (const adv of result.advisories) {
|
|
243
|
-
if (!adv.cve_id) continue;
|
|
244
|
-
if (existing.has(adv.cve_id)) continue;
|
|
395
|
+
if (!adv.cve_id) { ghsaOnlySkipped++; continue; }
|
|
245
396
|
const normalized = normalizeAdvisory(adv);
|
|
246
397
|
if (!normalized) continue;
|
|
398
|
+
if (existing.has(adv.cve_id)) {
|
|
399
|
+
// Finding 9: field-dropped detection on the existing entry.
|
|
400
|
+
const before = cveCatalog[adv.cve_id] || {};
|
|
401
|
+
const after = normalized[adv.cve_id];
|
|
402
|
+
for (const field of FIELD_DROPPED_WATCH) {
|
|
403
|
+
const had = before[field];
|
|
404
|
+
const has = after[field];
|
|
405
|
+
const wasPopulated = had !== null && had !== undefined && had !== "" && had !== false;
|
|
406
|
+
const isNowEmpty = has === null || has === undefined;
|
|
407
|
+
if (wasPopulated && isNowEmpty) {
|
|
408
|
+
diffs.push({
|
|
409
|
+
id: adv.cve_id,
|
|
410
|
+
field,
|
|
411
|
+
before: had,
|
|
412
|
+
after: null,
|
|
413
|
+
severity: null,
|
|
414
|
+
source: "ghsa",
|
|
415
|
+
variant: "field_dropped",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
247
421
|
diffs.push({
|
|
248
422
|
id: adv.cve_id,
|
|
249
423
|
field: "_new_entry",
|
|
@@ -257,9 +431,17 @@ async function buildDiff(ctx) {
|
|
|
257
431
|
status: "ok",
|
|
258
432
|
diffs,
|
|
259
433
|
errors: 0,
|
|
260
|
-
|
|
434
|
+
ghsa_only_skipped: ghsaOnlySkipped,
|
|
435
|
+
summary: `GHSA returned ${result.advisories.length} reviewed advisories; ${diffs.length} new CVE ID(s) not yet in local catalog, ${ghsaOnlySkipped} ghsa_only_skipped.`,
|
|
261
436
|
rate_limit: result.rate_limit || null,
|
|
262
437
|
};
|
|
263
438
|
}
|
|
264
439
|
|
|
265
|
-
module.exports = {
|
|
440
|
+
module.exports = {
|
|
441
|
+
fetchAdvisories,
|
|
442
|
+
fetchAdvisoryById,
|
|
443
|
+
normalizeAdvisory,
|
|
444
|
+
buildDiff,
|
|
445
|
+
FIELD_DROPPED_WATCH,
|
|
446
|
+
safeDateSlice,
|
|
447
|
+
};
|