@blamejs/exceptd-skills 0.18.6 → 0.18.8
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 +42 -0
- package/bin/exceptd.js +364 -119
- package/data/_indexes/_meta.json +22 -3
- package/data/cve-catalog.json +25 -0
- package/data/playbooks/framework.json +2 -2
- package/data/playbooks/post-quantum-migration.json +1 -1
- package/lib/auto-discovery.js +30 -10
- package/lib/collectors/ai-api.js +9 -2
- package/lib/collectors/cicd-pipeline-compromise.js +24 -5
- package/lib/collectors/cred-stores.js +17 -4
- package/lib/collectors/crypto.js +9 -2
- package/lib/collectors/hardening.js +9 -2
- package/lib/collectors/library-author.js +29 -5
- package/lib/collectors/mcp.js +9 -2
- package/lib/collectors/runtime.js +9 -2
- package/lib/collectors/sbom.js +28 -15
- package/lib/collectors/scan-excludes.js +25 -0
- package/lib/collectors/secrets.js +40 -4
- package/lib/cve-curation.js +84 -8
- package/lib/lint-skills.js +75 -3
- package/lib/playbook-runner.js +443 -50
- package/lib/prefetch.js +22 -2
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +235 -26
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +141 -21
- package/lib/sign.js +107 -29
- package/lib/source-advisories.js +23 -5
- package/lib/source-ghsa.js +25 -1
- package/lib/source-osv.js +26 -1
- package/lib/upstream-check.js +1 -1
- package/lib/validate-cve-catalog.js +30 -4
- package/lib/validate-indexes.js +135 -29
- package/lib/validate-playbooks.js +19 -7
- package/lib/validate-vendor.js +69 -8
- package/lib/verify.js +23 -6
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +14 -3
- package/orchestrator/index.js +100 -20
- package/orchestrator/scanner.js +8 -0
- package/package.json +1 -1
- package/sbom.cdx.json +130 -130
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +84 -11
- package/scripts/check-agents-md-collectors.js +41 -13
- package/scripts/check-changelog-extract.js +4 -4
- package/scripts/check-codebase-patterns.js +19 -5
- package/scripts/check-manifest-snapshot.js +74 -30
- package/scripts/check-sbom-currency.js +25 -5
- package/scripts/check-test-count.js +26 -7
- package/scripts/check-test-coverage.js +44 -4
- package/scripts/check-version-tags.js +27 -8
- package/scripts/predeploy.js +1 -1
- package/scripts/refresh-manifest-snapshot.js +14 -4
- package/scripts/refresh-reverse-refs.js +7 -1
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/release.js +3 -3
- package/scripts/run-e2e-scenarios.js +18 -8
- package/scripts/validate-vendor-online.js +28 -2
- package/scripts/verify-shipped-tarball.js +65 -6
- package/sources/validators/cve-validator.js +17 -1
- package/vendor/blamejs/_PROVENANCE.json +4 -2
package/lib/scoring.js
CHANGED
|
@@ -81,13 +81,73 @@ const ACTIVE_EXPLOITATION_LADDER = {
|
|
|
81
81
|
none: 0,
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the active_exploitation ladder multiplier for a factor value.
|
|
86
|
+
*
|
|
87
|
+
* The bare `ACTIVE_EXPLOITATION_LADDER[v] ?? 0` lookup silently mapped any
|
|
88
|
+
* out-of-vocabulary string ('exploited', 'in-the-wild', a future vocabulary
|
|
89
|
+
* value) AND any case/whitespace variant ('Confirmed', ' CONFIRMED ') to 0 —
|
|
90
|
+
* dropping up to the full active_exploitation weight (20 pts) from the RWEP
|
|
91
|
+
* with no diagnostic, while validateFactors() flagged the same string. This
|
|
92
|
+
* is the recurring "out-of-vocab token -> silent zero" class: the no-match
|
|
93
|
+
* path must surface an error, not a silent default (same remedy as the
|
|
94
|
+
* playbook-runner condition-evaluator hyphen fix).
|
|
95
|
+
*
|
|
96
|
+
* - Case-normalises the lookup so 'Confirmed' / ' CONFIRMED ' resolve to the
|
|
97
|
+
* canonical ladder entry instead of zeroing.
|
|
98
|
+
* - null / undefined are the documented "treated as 'none'" default (mult 0,
|
|
99
|
+
* recognised) — these are not typos.
|
|
100
|
+
* - A non-empty string NOT in the ladder, or a non-string non-nullish value,
|
|
101
|
+
* is UNRECOGNISED: returns multiplier 0 AND emits a process warning so the
|
|
102
|
+
* zeroed factor is observable in the bare-number call path. The structured
|
|
103
|
+
* diagnostic for the collectWarnings path is produced by validateFactors().
|
|
104
|
+
*
|
|
105
|
+
* Returns { multiplier, recognised, normalised }.
|
|
106
|
+
*/
|
|
107
|
+
function resolveActiveExploitation(active_exploitation) {
|
|
108
|
+
if (active_exploitation === undefined || active_exploitation === null) {
|
|
109
|
+
// documented default: absent active_exploitation is scored as 'none'.
|
|
110
|
+
return { multiplier: ACTIVE_EXPLOITATION_LADDER.none, recognised: true, normalised: 'none' };
|
|
111
|
+
}
|
|
112
|
+
if (typeof active_exploitation === 'string') {
|
|
113
|
+
const norm = active_exploitation.trim().toLowerCase();
|
|
114
|
+
if (Object.prototype.hasOwnProperty.call(ACTIVE_EXPLOITATION_LADDER, norm)) {
|
|
115
|
+
return { multiplier: ACTIVE_EXPLOITATION_LADDER[norm], recognised: true, normalised: norm };
|
|
116
|
+
}
|
|
117
|
+
return { multiplier: 0, recognised: false, normalised: norm };
|
|
118
|
+
}
|
|
119
|
+
return { multiplier: 0, recognised: false, normalised: null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function activeExploitationMultiplier(active_exploitation) {
|
|
123
|
+
const r = resolveActiveExploitation(active_exploitation);
|
|
124
|
+
if (!r.recognised) {
|
|
125
|
+
// Observable diagnostic for the bare-number call path (scoreCustom without
|
|
126
|
+
// collectWarnings, which the production write-paths use). Routed through
|
|
127
|
+
// process.emitWarning so it lands on the standard Node diagnostic channel
|
|
128
|
+
// without changing the function's number return contract; deduped per
|
|
129
|
+
// distinct offending value so a batch curation run doesn't flood stderr.
|
|
130
|
+
const detail = active_exploitation === undefined || active_exploitation === null
|
|
131
|
+
? String(active_exploitation)
|
|
132
|
+
: (typeof active_exploitation === 'string' ? JSON.stringify(active_exploitation) : `${typeof active_exploitation} ${JSON.stringify(active_exploitation)}`);
|
|
133
|
+
process.emitWarning(
|
|
134
|
+
`active_exploitation ${detail} is not in the recognised ladder (${Object.keys(ACTIVE_EXPLOITATION_LADDER).join(', ')}); contributing 0 active-exploitation weight`,
|
|
135
|
+
{ type: 'RwepActiveExploitationUnrecognised', code: 'RWEP_AE_UNRECOGNISED' },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return r.multiplier;
|
|
139
|
+
}
|
|
140
|
+
|
|
84
141
|
// The canonical set of factor keys scoreCustom recognises. Used by
|
|
85
142
|
// validateFactors to flag unknown keys.
|
|
86
143
|
const RECOGNISED_FACTOR_KEYS = new Set([
|
|
87
144
|
'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
88
145
|
'active_exploitation', 'blast_radius', 'patch_available',
|
|
89
146
|
'live_patch_available', 'reboot_required',
|
|
90
|
-
// accepted
|
|
147
|
+
// accepted aliases for the catalog field names: a factor bag built straight
|
|
148
|
+
// from a catalog entry carries `ai_assisted_weaponization` (the field the
|
|
149
|
+
// catalog declares) and `patch_required_reboot`, not the legacy short forms.
|
|
150
|
+
'ai_assisted_weaponization',
|
|
91
151
|
'patch_required_reboot',
|
|
92
152
|
]);
|
|
93
153
|
|
|
@@ -119,17 +179,34 @@ function validateFactors(factors) {
|
|
|
119
179
|
const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
120
180
|
'patch_available', 'live_patch_available', 'reboot_required'];
|
|
121
181
|
for (const f of boolFields) {
|
|
122
|
-
|
|
182
|
+
// The catalog field `ai_assisted_weaponization` satisfies `ai_assisted_weapon`,
|
|
183
|
+
// and `patch_required_reboot` satisfies `reboot_required` — the same aliasing
|
|
184
|
+
// scoreCustom/deriveRwepFromFactors honor, so validateFactors must accept a
|
|
185
|
+
// block that supplies only the alias instead of flagging it "missing".
|
|
186
|
+
const present = (f === 'ai_assisted_weapon')
|
|
187
|
+
? (factors.ai_assisted_weapon ?? factors.ai_assisted_weaponization)
|
|
188
|
+
: (f === 'reboot_required')
|
|
189
|
+
? (factors.reboot_required ?? factors.patch_required_reboot)
|
|
190
|
+
: factors[f];
|
|
191
|
+
if (present === undefined || present === null) {
|
|
123
192
|
warnings.push(`${f}: missing (treated as false; explicit value recommended)`);
|
|
124
|
-
} else if (typeof
|
|
125
|
-
warnings.push(`${f}: expected boolean, got ${typeof
|
|
193
|
+
} else if (typeof present !== 'boolean') {
|
|
194
|
+
warnings.push(`${f}: expected boolean, got ${typeof present} (${JSON.stringify(present)})`);
|
|
126
195
|
}
|
|
127
196
|
}
|
|
128
197
|
const aeAllowed = ['none', 'unknown', 'suspected', 'theoretical', 'confirmed'];
|
|
129
|
-
|
|
198
|
+
const aeRaw = factors.active_exploitation;
|
|
199
|
+
if (aeRaw === undefined || aeRaw === null) {
|
|
130
200
|
warnings.push("active_exploitation: missing (treated as 'none')");
|
|
131
|
-
} else
|
|
132
|
-
|
|
201
|
+
} else {
|
|
202
|
+
// Normalize (trim + lowercase) before the vocab check so validateFactors
|
|
203
|
+
// accepts exactly what scoreCustom/resolveActiveExploitation accept — a
|
|
204
|
+
// stray-cased 'Confirmed' / ' confirmed ' must not be flagged here while the
|
|
205
|
+
// scorer consumes it, or the two surfaces disagree.
|
|
206
|
+
const aeNorm = typeof aeRaw === 'string' ? aeRaw.trim().toLowerCase() : aeRaw;
|
|
207
|
+
if (!aeAllowed.includes(aeNorm)) {
|
|
208
|
+
warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(aeRaw)}`);
|
|
209
|
+
}
|
|
133
210
|
}
|
|
134
211
|
// NaN diagnostics. The prior message read "expected number,
|
|
135
212
|
// got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
|
|
@@ -180,6 +257,10 @@ function scoreCustom(factors, opts) {
|
|
|
180
257
|
cisa_kev = false,
|
|
181
258
|
poc_available = false,
|
|
182
259
|
ai_assisted_weapon = false,
|
|
260
|
+
// The catalog field is `ai_assisted_weaponization`; accept it as an alias
|
|
261
|
+
// so a factor bag built directly from a catalog entry still counts the AI
|
|
262
|
+
// factor instead of silently dropping the +15 weight.
|
|
263
|
+
ai_assisted_weaponization = false,
|
|
183
264
|
ai_discovered = false,
|
|
184
265
|
active_exploitation = 'none',
|
|
185
266
|
blast_radius = 0,
|
|
@@ -198,7 +279,7 @@ function scoreCustom(factors, opts) {
|
|
|
198
279
|
let score = 0;
|
|
199
280
|
score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
|
|
200
281
|
score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
|
|
201
|
-
score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
|
|
282
|
+
score += (ai_assisted_weapon || ai_assisted_weaponization || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
|
|
202
283
|
// active_exploitation goes through the ladder rather
|
|
203
284
|
// than two hand-written branches with `Math.floor(weight/2)`. The floor
|
|
204
285
|
// was a no-op for even weights (20/2 = 10) but would have silently
|
|
@@ -206,7 +287,7 @@ function scoreCustom(factors, opts) {
|
|
|
206
287
|
// weight to 21. The ladder + multiplication preserves the contribution
|
|
207
288
|
// exactly, including the new `unknown → 0.25 × weight = 5` mapping that
|
|
208
289
|
// aligns the catalog scorer with playbook-runner._activeExploitationLadder.
|
|
209
|
-
const aeMultiplier =
|
|
290
|
+
const aeMultiplier = activeExploitationMultiplier(active_exploitation);
|
|
210
291
|
score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
|
|
211
292
|
// v0.12.15: blast_radius numeric coercion must reject
|
|
212
293
|
// NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
|
|
@@ -278,15 +359,38 @@ function deriveRwepFromFactors(factors) {
|
|
|
278
359
|
if (values.length === 0) return 0;
|
|
279
360
|
const aeAllowed = new Set(['none', 'unknown', 'suspected', 'theoretical', 'confirmed']);
|
|
280
361
|
const hasBooleanOrLadder = values.some(
|
|
281
|
-
(v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v)),
|
|
362
|
+
(v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase())),
|
|
282
363
|
);
|
|
283
364
|
if (hasBooleanOrLadder) {
|
|
284
365
|
return scoreCustom(factors);
|
|
285
366
|
}
|
|
286
367
|
// Shape B: catalog post-weight. Sum + clamp.
|
|
368
|
+
//
|
|
369
|
+
// blast_radius is the one Shape B field with a per-factor ceiling: it is a
|
|
370
|
+
// RAW 0..30 magnitude, not a post-weight contribution (see the dual-semantics
|
|
371
|
+
// note at the top of this file). Clamp it to [0, RWEP_WEIGHTS.blast_radius]
|
|
372
|
+
// before summing — exactly as scoreCustom does — so an out-of-range stored
|
|
373
|
+
// value (a unit error such as 300, or a negative) cannot silently inflate or
|
|
374
|
+
// zero the result by being absorbed only by the final aggregate clamp. Both
|
|
375
|
+
// paths now produce the same score for the same factors, so the validate()
|
|
376
|
+
// recompute-vs-stored divergence gate stays meaningful instead of flagging a
|
|
377
|
+
// self-inconsistency the two scorers introduced. Every other Shape B value is
|
|
378
|
+
// already a bounded post-weight contribution, so only blast_radius needs the
|
|
379
|
+
// per-factor clamp.
|
|
287
380
|
let sum = 0;
|
|
288
|
-
for (const v of
|
|
289
|
-
if (typeof v
|
|
381
|
+
for (const [k, v] of Object.entries(factors)) {
|
|
382
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
|
|
383
|
+
// reboot_required and patch_required_reboot are aliases for the SAME
|
|
384
|
+
// post-weight contribution (scoreCustom collapses them). A block carrying
|
|
385
|
+
// both must count it once; summing both double-counts the reboot weight,
|
|
386
|
+
// inflating the derived RWEP past the formula AND past the stored score the
|
|
387
|
+
// validate() divergence gate compares against.
|
|
388
|
+
if (k === 'patch_required_reboot' && Object.prototype.hasOwnProperty.call(factors, 'reboot_required')) continue;
|
|
389
|
+
if (k === 'blast_radius') {
|
|
390
|
+
sum += Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, v));
|
|
391
|
+
} else {
|
|
392
|
+
sum += v;
|
|
393
|
+
}
|
|
290
394
|
}
|
|
291
395
|
return Math.max(0, Math.min(100, sum));
|
|
292
396
|
}
|
|
@@ -333,7 +437,11 @@ function compare(cveId, catalog, opts) {
|
|
|
333
437
|
// delta is null (not NaN) when there is no CVSS to compare against, so the
|
|
334
438
|
// emitted result serializes cleanly and never claims a numeric divergence
|
|
335
439
|
// that does not exist.
|
|
336
|
-
|
|
440
|
+
// Guard the RWEP side exactly like CVSS above: an absent or non-finite
|
|
441
|
+
// rwep_score must not flow into `rwep - cvssEquivalent` — NaN poisons the
|
|
442
|
+
// delta, fails every band, and falls through to a false "broadly aligned".
|
|
443
|
+
const rwepValid = (typeof rwep === 'number' && Number.isFinite(rwep));
|
|
444
|
+
const delta = (cvssAbsent || !rwepValid) ? null : rwep - cvssEquivalent;
|
|
337
445
|
|
|
338
446
|
// narrow the "broadly aligned" band from ±20 to ±10. The old
|
|
339
447
|
// ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
|
|
@@ -347,7 +455,9 @@ function compare(cveId, catalog, opts) {
|
|
|
347
455
|
// aligned" — coincidence-passing per the field-present-not-populated
|
|
348
456
|
// pitfall. Now the operator sees a specific signal pointing at the
|
|
349
457
|
// catalog gap rather than a false sense of alignment.
|
|
350
|
-
if (
|
|
458
|
+
if (!rwepValid) {
|
|
459
|
+
explanation = 'RWEP score absent or non-numeric for this CVE — no usable RWEP signal to compare. Backfill rwep_score / rwep_factors in the catalog.';
|
|
460
|
+
} else if ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
|
|
351
461
|
explanation = 'No scoring signal — both RWEP and CVSS are zero/null. Investigate the catalog entry; this CVE has no usable risk score.';
|
|
352
462
|
} else if (cvssAbsent) {
|
|
353
463
|
// RWEP carries a real signal but there is no CVSS to compare it against.
|
|
@@ -356,12 +466,17 @@ function compare(cveId, catalog, opts) {
|
|
|
356
466
|
explanation = 'CVSS absent — RWEP is the only usable score for this CVE; no CVSS comparison is possible. Backfill cvss_score in the catalog to enable the comparison.';
|
|
357
467
|
} else if (delta > 10) {
|
|
358
468
|
explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
|
|
469
|
+
// The explanation must list every factor scoreCustom actually counts, via
|
|
470
|
+
// the same aliases/normalization — otherwise a CVE whose RWEP is driven by
|
|
471
|
+
// ai_assisted_weaponization (not ai_discovered), a stray-cased 'Confirmed',
|
|
472
|
+
// or the patch_required_reboot alias shows a higher RWEP with no stated
|
|
473
|
+
// reason for the delta.
|
|
359
474
|
const driving = [];
|
|
360
475
|
if (entry.cisa_kev) driving.push('CISA KEV (+25)');
|
|
361
476
|
if (entry.poc_available) driving.push('public PoC (+20)');
|
|
362
|
-
if (entry.ai_discovered) driving.push('AI-discovered (+15 weaponization)');
|
|
363
|
-
if (entry.active_exploitation === 'confirmed') driving.push('confirmed exploitation (+20)');
|
|
364
|
-
if (entry.patch_required_reboot && !entry.live_patch_available) driving.push('reboot required (+5)');
|
|
477
|
+
if (entry.ai_discovered || entry.ai_assisted_weaponization) driving.push('AI-discovered (+15 weaponization)');
|
|
478
|
+
if (String(entry.active_exploitation || '').trim().toLowerCase() === 'confirmed') driving.push('confirmed exploitation (+20)');
|
|
479
|
+
if ((entry.reboot_required || entry.patch_required_reboot) && !entry.live_patch_available) driving.push('reboot required (+5)');
|
|
365
480
|
explanation += driving.join(', ');
|
|
366
481
|
explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
|
|
367
482
|
} else if (delta < -10) {
|
|
@@ -379,9 +494,9 @@ function compare(cveId, catalog, opts) {
|
|
|
379
494
|
const out = {
|
|
380
495
|
cve_id: cveId,
|
|
381
496
|
cvss: cvss,
|
|
382
|
-
rwep: rwep,
|
|
497
|
+
rwep: rwepValid ? rwep : null,
|
|
383
498
|
cvss_framework_sla: timeline(cvssEquivalent),
|
|
384
|
-
rwep_actual_sla: timeline(rwep),
|
|
499
|
+
rwep_actual_sla: rwepValid ? timeline(rwep) : { hours: null, label: 'RWEP score unavailable' },
|
|
385
500
|
delta,
|
|
386
501
|
explanation,
|
|
387
502
|
};
|
|
@@ -485,7 +600,7 @@ function validate(catalog) {
|
|
|
485
600
|
// one judgment-set factor with no deriving source field.
|
|
486
601
|
if (shape === 'B') {
|
|
487
602
|
const f = entry.rwep_factors;
|
|
488
|
-
const aeMultiplier =
|
|
603
|
+
const aeMultiplier = resolveActiveExploitation(entry.active_exploitation).multiplier;
|
|
489
604
|
const implied = {
|
|
490
605
|
cisa_kev: entry.cisa_kev === true ? RWEP_WEIGHTS.cisa_kev : 0,
|
|
491
606
|
poc_available: entry.poc_available === true ? RWEP_WEIGHTS.poc_available : 0,
|
|
@@ -521,7 +636,10 @@ function validate(catalog) {
|
|
|
521
636
|
blast_radius: entry.rwep_factors ? entry.rwep_factors.blast_radius : 0,
|
|
522
637
|
patch_available: entry.patch_available,
|
|
523
638
|
live_patch_available: entry.live_patch_available,
|
|
524
|
-
reboot_required
|
|
639
|
+
// Mirror the reboot alias scoreCustom itself honors (reboot_required OR
|
|
640
|
+
// patch_required_reboot): passing only patch_required_reboot would drop a
|
|
641
|
+
// top-level reboot_required field and compute a divergent expected RWEP.
|
|
642
|
+
reboot_required: entry.reboot_required || entry.patch_required_reboot
|
|
525
643
|
});
|
|
526
644
|
if (Math.abs(calculatedRwep - entry.rwep_score) > 5) {
|
|
527
645
|
errors.push(`${cveId}: rwep_score ${entry.rwep_score} diverges from calculated ${calculatedRwep} by more than 5 — verify factors`);
|
|
@@ -612,6 +730,8 @@ module.exports = {
|
|
|
612
730
|
validateFactors,
|
|
613
731
|
deriveRwepFromFactors,
|
|
614
732
|
parseCvss31Vector,
|
|
733
|
+
resolveActiveExploitation,
|
|
734
|
+
activeExploitationMultiplier,
|
|
615
735
|
RWEP_WEIGHTS,
|
|
616
736
|
ACTIVE_EXPLOITATION_LADDER,
|
|
617
737
|
RECOGNISED_FACTOR_KEYS,
|
package/lib/sign.js
CHANGED
|
@@ -52,9 +52,12 @@
|
|
|
52
52
|
* .keys/private.pem on Windows, restrictWindowsAcl() shells to icacls
|
|
53
53
|
* to strip inherited entries and grant Full Control only to the current
|
|
54
54
|
* user. If icacls is unavailable (Server Core, exotic shells), the call
|
|
55
|
-
* warns to stderr
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* warns to stderr and generateKeypair() returns { aclHardened: false }.
|
|
56
|
+
* The CLI dispatch then exits non-zero so automation (bootstrap.js,
|
|
57
|
+
* doctor --fix) does not treat an unhardened key as a clean generation —
|
|
58
|
+
* set EXCEPTD_ALLOW_WEAK_KEY_ACL=1 to accept the weaker ACL on a
|
|
59
|
+
* single-user host. (The key write itself still succeeds; the non-zero
|
|
60
|
+
* exit signals "key present but not ACL-hardened," not "no key.")
|
|
58
61
|
*
|
|
59
62
|
* Signing ceremony:
|
|
60
63
|
* 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
|
|
@@ -78,6 +81,12 @@ const path = require('path');
|
|
|
78
81
|
const crypto = require('crypto');
|
|
79
82
|
const { execFileSync } = require('child_process');
|
|
80
83
|
const { safeExit } = require('./exit-codes');
|
|
84
|
+
// Reuse the EXACT validator lib/verify.js applies at load time, so the signer
|
|
85
|
+
// and the verifier can never disagree about what a well-formed manifest is.
|
|
86
|
+
// Requiring verify.js is side-effect-free (its CLI block is guarded by
|
|
87
|
+
// `require.main === module`) and verify.js does not require sign.js, so there
|
|
88
|
+
// is no circular dependency.
|
|
89
|
+
const { validateAgainstSchema } = require('./verify');
|
|
81
90
|
|
|
82
91
|
const ROOT = path.join(__dirname, '..');
|
|
83
92
|
const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
|
|
@@ -85,6 +94,7 @@ const KEYS_DIR = path.join(ROOT, '.keys');
|
|
|
85
94
|
const PUBLIC_KEYS_DIR = path.join(ROOT, 'keys');
|
|
86
95
|
const PRIVATE_KEY_PATH = path.join(KEYS_DIR, 'private.pem');
|
|
87
96
|
const PUBLIC_KEY_PATH = path.join(PUBLIC_KEYS_DIR, 'public.pem');
|
|
97
|
+
const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
|
|
88
98
|
|
|
89
99
|
// --- public API ---
|
|
90
100
|
|
|
@@ -96,28 +106,6 @@ const PUBLIC_KEY_PATH = path.join(PUBLIC_KEYS_DIR, 'public.pem');
|
|
|
96
106
|
* @param {{ rotate: boolean }} options
|
|
97
107
|
*/
|
|
98
108
|
function generateKeypair({ rotate = false } = {}) {
|
|
99
|
-
if (fs.existsSync(PRIVATE_KEY_PATH) && !rotate) {
|
|
100
|
-
console.error('[sign] Private key already exists at .keys/private.pem');
|
|
101
|
-
console.error('[sign] Use --rotate to generate a new keypair and invalidate existing signatures.');
|
|
102
|
-
process.exit(1);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Refuse to silently overwrite an existing public key when no private key
|
|
106
|
-
// is present. This is the v0.11.x signature-regression class: a host with
|
|
107
|
-
// a working pubkey but missing privkey running generate-keypair would
|
|
108
|
-
// produce a fresh pubkey divergent from every shipped signature. Operators
|
|
109
|
-
// running `exceptd doctor --fix` on a stock install would replace the
|
|
110
|
-
// shipped keys/public.pem with one whose private half exists only on
|
|
111
|
-
// their machine — every subsequent verify against shipped signatures fails.
|
|
112
|
-
// Force the operator to be explicit via --rotate (which signals intent to
|
|
113
|
-
// re-sign).
|
|
114
|
-
if (fs.existsSync(PUBLIC_KEY_PATH) && !rotate) {
|
|
115
|
-
console.error('[sign] Public key already exists at keys/public.pem but no matching private key.');
|
|
116
|
-
console.error('[sign] Refusing to overwrite the public key — that would orphan every existing signature.');
|
|
117
|
-
console.error('[sign] If you are setting up a fresh signing identity, pass --rotate to confirm. After --rotate you must re-sign all skills with sign-all.');
|
|
118
|
-
process.exit(1);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
109
|
fs.mkdirSync(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
122
110
|
fs.mkdirSync(PUBLIC_KEYS_DIR, { recursive: true });
|
|
123
111
|
|
|
@@ -126,8 +114,49 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
126
114
|
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
|
127
115
|
});
|
|
128
116
|
|
|
129
|
-
|
|
130
|
-
|
|
117
|
+
// Atomic create-exclusive (flag 'wx' = O_CREAT|O_EXCL): the open itself
|
|
118
|
+
// refuses an existing key — there is no separate existsSync to race against,
|
|
119
|
+
// and exclusive-create won't follow a symlink/file an attacker preplanted at
|
|
120
|
+
// the key path. EEXIST translates to the same operator-facing refusals the
|
|
121
|
+
// bootstrap has always surfaced; --rotate is a deliberate re-key (flag 'w').
|
|
122
|
+
// The public-key refusal is the v0.11.x signature-regression guard: a host
|
|
123
|
+
// with a working pubkey but no privkey must NOT silently regenerate the
|
|
124
|
+
// pubkey (that orphans every shipped signature) — force --rotate to confirm.
|
|
125
|
+
const openFlag = rotate ? 'w' : 'wx';
|
|
126
|
+
let privFd;
|
|
127
|
+
try {
|
|
128
|
+
privFd = fs.openSync(PRIVATE_KEY_PATH, openFlag, 0o600);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e.code === 'EEXIST') {
|
|
131
|
+
console.error('[sign] Private key already exists at .keys/private.pem');
|
|
132
|
+
console.error('[sign] Use --rotate to generate a new keypair and invalidate existing signatures.');
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
let pubFd;
|
|
138
|
+
try {
|
|
139
|
+
pubFd = fs.openSync(PUBLIC_KEY_PATH, openFlag, 0o644);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
fs.closeSync(privFd);
|
|
142
|
+
// A pubkey clash after we exclusively created the privkey must not leave a
|
|
143
|
+
// half-written signing identity behind.
|
|
144
|
+
try { fs.unlinkSync(PRIVATE_KEY_PATH); } catch { /* best effort */ }
|
|
145
|
+
if (e.code === 'EEXIST') {
|
|
146
|
+
console.error('[sign] Public key already exists at keys/public.pem but no matching private key.');
|
|
147
|
+
console.error('[sign] Refusing to overwrite the public key — that would orphan every existing signature.');
|
|
148
|
+
console.error('[sign] If you are setting up a fresh signing identity, pass --rotate to confirm. After --rotate you must re-sign all skills with sign-all.');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
fs.writeSync(privFd, privateKey);
|
|
155
|
+
fs.writeSync(pubFd, publicKey);
|
|
156
|
+
} finally {
|
|
157
|
+
fs.closeSync(privFd);
|
|
158
|
+
fs.closeSync(pubFd);
|
|
159
|
+
}
|
|
131
160
|
|
|
132
161
|
// on win32, fs.writeFileSync `mode` does not produce
|
|
133
162
|
// a POSIX-style restrictive ACL. Tighten via icacls so other desktop
|
|
@@ -174,6 +203,9 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
174
203
|
function signAll() {
|
|
175
204
|
const privateKey = loadPrivateKey();
|
|
176
205
|
const manifest = loadManifest();
|
|
206
|
+
// Schema-validate BEFORE any mutation/write — refuse to sign a manifest
|
|
207
|
+
// the verifier would reject. Matches lib/verify.js signAll().
|
|
208
|
+
validateManifestSchema(manifest, 'sign-all');
|
|
177
209
|
// Validate every entry's path before doing any I/O. Reject the whole
|
|
178
210
|
// manifest on the first traversal attempt — we never want to sign
|
|
179
211
|
// half a manifest then exit non-zero with a partial mutation.
|
|
@@ -230,6 +262,9 @@ function signAll() {
|
|
|
230
262
|
function signOne(skillName) {
|
|
231
263
|
const privateKey = loadPrivateKey();
|
|
232
264
|
const manifest = loadManifest();
|
|
265
|
+
// Schema-validate BEFORE any mutation/write — refuse to sign a manifest
|
|
266
|
+
// the verifier would reject. Matches lib/verify.js signAll().
|
|
267
|
+
validateManifestSchema(manifest, 'sign');
|
|
233
268
|
const skill = manifest.skills.find(s => s.name === skillName);
|
|
234
269
|
if (!skill) { console.error(`Skill not found: ${skillName}`); process.exit(1); }
|
|
235
270
|
|
|
@@ -329,6 +364,33 @@ function loadManifest() {
|
|
|
329
364
|
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
330
365
|
}
|
|
331
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Validate the manifest against lib/schemas/manifest.schema.json BEFORE
|
|
369
|
+
* signing, mirroring lib/verify.js signAll() (which throws on schema
|
|
370
|
+
* violation before re-signing). Without this the signer is the WEAKER
|
|
371
|
+
* check: a manifest that is path-safe but schema-invalid (unknown
|
|
372
|
+
* per-skill field, malformed version, bad atlas_ref, missing required
|
|
373
|
+
* field) gets a valid manifest_signature here, then lib/verify.js
|
|
374
|
+
* loadManifestValidated() THROWS on the same schema at install time and
|
|
375
|
+
* refuses to verify any skill. The producer must never emit an artifact
|
|
376
|
+
* the consumer rejects, so the signer is held to at least the verifier's
|
|
377
|
+
* bar. The validator itself is imported from lib/verify.js so the two
|
|
378
|
+
* halves can never drift to different rules.
|
|
379
|
+
*
|
|
380
|
+
* @param {object} manifest parsed manifest.json
|
|
381
|
+
* @param {string} who 'sign-all' | 'sign' — surfaced in the error
|
|
382
|
+
* @throws on any schema violation
|
|
383
|
+
*/
|
|
384
|
+
function validateManifestSchema(manifest, who) {
|
|
385
|
+
const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
|
|
386
|
+
const errors = validateAgainstSchema(manifest, schema, 'manifest');
|
|
387
|
+
if (errors.length > 0) {
|
|
388
|
+
const detail = errors.slice(0, 10).map(e => ' - ' + e).join('\n');
|
|
389
|
+
const more = errors.length > 10 ? `\n ...and ${errors.length - 10} more` : '';
|
|
390
|
+
throw new Error(`[sign] manifest.json failed schema validation before ${who} — refusing to sign:\n${detail}${more}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
332
394
|
/**
|
|
333
395
|
* canonical byte form of the manifest, used for both
|
|
334
396
|
* signing (lib/sign.js) and verification (lib/verify.js).
|
|
@@ -460,9 +522,24 @@ if (require.main === module) {
|
|
|
460
522
|
const arg = process.argv[3];
|
|
461
523
|
|
|
462
524
|
switch (cmd) {
|
|
463
|
-
case 'generate-keypair':
|
|
464
|
-
generateKeypair({ rotate: process.argv.includes('--rotate') });
|
|
525
|
+
case 'generate-keypair': {
|
|
526
|
+
const { aclHardened } = generateKeypair({ rotate: process.argv.includes('--rotate') });
|
|
527
|
+
// On win32 a failed icacls hardening leaves the private key inheriting
|
|
528
|
+
// the parent ACL — potentially readable by other desktop users. Make
|
|
529
|
+
// that detectable to automation (bootstrap.js, doctor --fix) instead of
|
|
530
|
+
// burying it in a mid-banner line under a 0 exit: fail loud unless the
|
|
531
|
+
// operator opts into the weaker ACL on a host where it is acceptable.
|
|
532
|
+
// exitCode (not process.exit) so the buffered stdout banner drains.
|
|
533
|
+
if (process.platform === 'win32' && aclHardened === false) {
|
|
534
|
+
if (process.env.EXCEPTD_ALLOW_WEAK_KEY_ACL === '1') {
|
|
535
|
+
console.warn('[sign] WARN: private-key ACL was NOT hardened; continuing because EXCEPTD_ALLOW_WEAK_KEY_ACL=1.');
|
|
536
|
+
} else {
|
|
537
|
+
console.error('[sign] ERROR: private key written but Windows ACL hardening FAILED — the key may be readable by other desktop users on this machine. Strip inheritance manually (icacls .keys\\private.pem /inheritance:r /grant:r "%USERNAME%":F), or set EXCEPTD_ALLOW_WEAK_KEY_ACL=1 to accept the weaker ACL on a single-user host.');
|
|
538
|
+
safeExit(1);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
465
541
|
break;
|
|
542
|
+
}
|
|
466
543
|
case 'sign-all':
|
|
467
544
|
signAll();
|
|
468
545
|
break;
|
|
@@ -502,6 +579,7 @@ module.exports = {
|
|
|
502
579
|
signOne,
|
|
503
580
|
normalize,
|
|
504
581
|
validateSkillPath,
|
|
582
|
+
validateManifestSchema,
|
|
505
583
|
canonicalManifestBytes,
|
|
506
584
|
signCanonicalManifest,
|
|
507
585
|
restrictWindowsAcl,
|
package/lib/source-advisories.js
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
|
|
50
50
|
const path = require('path');
|
|
51
51
|
const fs = require('fs');
|
|
52
|
+
const { withRetry } = require('../vendor/blamejs/retry.js');
|
|
52
53
|
|
|
53
54
|
const TODAY = new Date().toISOString().slice(0, 10);
|
|
54
55
|
|
|
@@ -338,13 +339,30 @@ async function fetchFeed(feed, ctx) {
|
|
|
338
339
|
return { ok: true, body: fs.readFileSync(p, 'utf8') };
|
|
339
340
|
}
|
|
340
341
|
if (typeof fetch !== 'function') return { ok: false, error: 'fetch() not available — Node 18+ required' };
|
|
341
|
-
|
|
342
|
+
// Adopt the vendored blamejs retry primitive (BJS-02): a transient feed
|
|
343
|
+
// failure (HTTP 429/5xx, timeout/abort, network reset) is retried with
|
|
344
|
+
// backoff + jitter instead of dropping the feed on the first hiccup. A
|
|
345
|
+
// permanent 4xx is not retried. Each attempt gets a fresh abort timer.
|
|
346
|
+
const attempt = async () => {
|
|
342
347
|
const ac = new AbortController();
|
|
343
348
|
const timer = setTimeout(() => ac.abort(), 8000);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
349
|
+
try {
|
|
350
|
+
const r = await fetch(feed.url, { signal: ac.signal, headers: { 'User-Agent': 'exceptd-advisories-poller/0.13.1 (+https://exceptd.com)' } });
|
|
351
|
+
if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.statusCode = r.status; throw e; }
|
|
352
|
+
return await r.text();
|
|
353
|
+
} finally {
|
|
354
|
+
clearTimeout(timer);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
const retryable = (err) => {
|
|
358
|
+
if (err && typeof err.statusCode === 'number') return err.statusCode === 429 || err.statusCode >= 500;
|
|
359
|
+
if (err && (err.name === 'AbortError' || err.name === 'TimeoutError')) return true;
|
|
360
|
+
const code = err && (err.code || (err.cause && err.cause.code));
|
|
361
|
+
return !!code && /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|EAGAIN|ENOTFOUND|ENETUNREACH|UND_ERR)/.test(String(code));
|
|
362
|
+
};
|
|
363
|
+
try {
|
|
364
|
+
const body = await withRetry(attempt, { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5, isRetryable: retryable });
|
|
365
|
+
return { ok: true, body };
|
|
348
366
|
} catch (e) {
|
|
349
367
|
return { ok: false, error: e.message || String(e) };
|
|
350
368
|
}
|
package/lib/source-ghsa.js
CHANGED
|
@@ -33,6 +33,16 @@ const GHSA_PATH = "/advisories?per_page=50&type=reviewed&sort=published&directio
|
|
|
33
33
|
const REQUEST_TIMEOUT_MS = 10000;
|
|
34
34
|
const USER_AGENT = "exceptd-security/source-ghsa (+https://exceptd.com)";
|
|
35
35
|
|
|
36
|
+
// Cap the streamed response so a hijacked DNS / compromised CDN / MITM serving
|
|
37
|
+
// api.github.com can't stream unbounded bytes into RAM. A 50-advisory page is
|
|
38
|
+
// well under a MB; 16 MB is generous headroom. Symmetric with the JSON cap
|
|
39
|
+
// getJsonOnce enforces in lib/refresh-network.js and the OSV poller's cap.
|
|
40
|
+
// Tunable via EXCEPTD_GHSA_RESPONSE_CAP_BYTES for future API growth.
|
|
41
|
+
function ghsaResponseCapBytes() {
|
|
42
|
+
const env = parseInt(process.env.EXCEPTD_GHSA_RESPONSE_CAP_BYTES, 10);
|
|
43
|
+
return Number.isFinite(env) && env > 0 ? env : 16 * 1024 * 1024;
|
|
44
|
+
}
|
|
45
|
+
|
|
36
46
|
/**
|
|
37
47
|
* Field-dropped watch set — fields the buildDiff regression-detector
|
|
38
48
|
* watches when the upstream still has an entry but a previously-populated
|
|
@@ -123,8 +133,22 @@ function ghsaRequestOnce({ path, headers, timeoutMs }) {
|
|
|
123
133
|
return resolve({ ok: false, error: `GHSA returned HTTP ${status}`, source: "offline" });
|
|
124
134
|
}
|
|
125
135
|
const chunks = [];
|
|
126
|
-
|
|
136
|
+
let total = 0;
|
|
137
|
+
const cap = ghsaResponseCapBytes();
|
|
138
|
+
let capped = false;
|
|
139
|
+
res.on("data", (c) => {
|
|
140
|
+
total += c.length;
|
|
141
|
+
if (total > cap) {
|
|
142
|
+
capped = true;
|
|
143
|
+
req.destroy(new Error(`GHSA response exceeds ${cap}-byte cap during streaming download`));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
chunks.push(c);
|
|
147
|
+
});
|
|
127
148
|
res.on("end", () => {
|
|
149
|
+
// If the cap fired, req.destroy already routed through req.on("error");
|
|
150
|
+
// don't also resolve here (avoids a double-settle on the promise).
|
|
151
|
+
if (capped) return;
|
|
128
152
|
try {
|
|
129
153
|
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
130
154
|
const advisories = Array.isArray(body) ? body : (body ? [body] : []);
|
package/lib/source-osv.js
CHANGED
|
@@ -47,6 +47,17 @@ const OSV_HOST = "api.osv.dev";
|
|
|
47
47
|
const REQUEST_TIMEOUT_MS = 10000;
|
|
48
48
|
const USER_AGENT = "exceptd-security/source-osv (+https://exceptd.com)";
|
|
49
49
|
|
|
50
|
+
// Cap the streamed response so a hostile or misconfigured endpoint (a hijacked
|
|
51
|
+
// DNS / compromised CDN / OSV_HOST_OVERRIDE pointed at a malicious server)
|
|
52
|
+
// can't stream unbounded bytes into RAM. A real /v1/vulns/{id} or /v1/query
|
|
53
|
+
// response is a few KB; 16 MB is generous headroom. Symmetric with the JSON
|
|
54
|
+
// cap getJsonOnce enforces in lib/refresh-network.js. Tunable via
|
|
55
|
+
// EXCEPTD_OSV_RESPONSE_CAP_BYTES for future API growth.
|
|
56
|
+
function osvResponseCapBytes() {
|
|
57
|
+
const env = parseInt(process.env.EXCEPTD_OSV_RESPONSE_CAP_BYTES, 10);
|
|
58
|
+
return Number.isFinite(env) && env > 0 ? env : 16 * 1024 * 1024;
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
// Identifier namespaces OSV uses as PRIMARY keys. GHSA-* is intentionally
|
|
51
62
|
// NOT in this list — `seedSingleAdvisory` in lib/refresh-external.js routes
|
|
52
63
|
// CVE-* and GHSA-* through `source-ghsa` because GHSA carries richer field
|
|
@@ -214,8 +225,22 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
|
|
|
214
225
|
return resolve({ ok: false, error, status, source: "offline" });
|
|
215
226
|
}
|
|
216
227
|
const chunks = [];
|
|
217
|
-
|
|
228
|
+
let total = 0;
|
|
229
|
+
const cap = osvResponseCapBytes();
|
|
230
|
+
let capped = false;
|
|
231
|
+
res.on("data", (c) => {
|
|
232
|
+
total += c.length;
|
|
233
|
+
if (total > cap) {
|
|
234
|
+
capped = true;
|
|
235
|
+
req.destroy(new Error(`OSV response exceeds ${cap}-byte cap during streaming download`));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
chunks.push(c);
|
|
239
|
+
});
|
|
218
240
|
res.on("end", () => {
|
|
241
|
+
// If the cap fired, req.destroy already routed through req.on("error");
|
|
242
|
+
// don't also resolve here (avoids a double-settle on the promise).
|
|
243
|
+
if (capped) return;
|
|
219
244
|
try {
|
|
220
245
|
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
221
246
|
resolve({ ok: true, record: parsed, source: "osv-api" });
|
package/lib/upstream-check.js
CHANGED
|
@@ -55,7 +55,7 @@ async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName =
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
return new Promise((resolve) => {
|
|
58
|
-
const path = `/${encodeURIComponent(pkgName).replace(
|
|
58
|
+
const path = `/${encodeURIComponent(pkgName).replace(/%40/g, "@").replace(/%2F/g, "/")}`;
|
|
59
59
|
const req = https.get({
|
|
60
60
|
host: REGISTRY_HOST,
|
|
61
61
|
path,
|