@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bin/exceptd.js +364 -119
  3. package/data/_indexes/_meta.json +22 -3
  4. package/data/cve-catalog.json +25 -0
  5. package/data/playbooks/framework.json +2 -2
  6. package/data/playbooks/post-quantum-migration.json +1 -1
  7. package/lib/auto-discovery.js +30 -10
  8. package/lib/collectors/ai-api.js +9 -2
  9. package/lib/collectors/cicd-pipeline-compromise.js +24 -5
  10. package/lib/collectors/cred-stores.js +17 -4
  11. package/lib/collectors/crypto.js +9 -2
  12. package/lib/collectors/hardening.js +9 -2
  13. package/lib/collectors/library-author.js +29 -5
  14. package/lib/collectors/mcp.js +9 -2
  15. package/lib/collectors/runtime.js +9 -2
  16. package/lib/collectors/sbom.js +28 -15
  17. package/lib/collectors/scan-excludes.js +25 -0
  18. package/lib/collectors/secrets.js +40 -4
  19. package/lib/cve-curation.js +84 -8
  20. package/lib/lint-skills.js +75 -3
  21. package/lib/playbook-runner.js +443 -50
  22. package/lib/prefetch.js +22 -2
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +235 -26
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +141 -21
  27. package/lib/sign.js +107 -29
  28. package/lib/source-advisories.js +23 -5
  29. package/lib/source-ghsa.js +25 -1
  30. package/lib/source-osv.js +26 -1
  31. package/lib/upstream-check.js +1 -1
  32. package/lib/validate-cve-catalog.js +30 -4
  33. package/lib/validate-indexes.js +135 -29
  34. package/lib/validate-playbooks.js +19 -7
  35. package/lib/validate-vendor.js +69 -8
  36. package/lib/verify.js +23 -6
  37. package/manifest.json +53 -53
  38. package/orchestrator/dispatcher.js +14 -3
  39. package/orchestrator/index.js +100 -20
  40. package/orchestrator/scanner.js +8 -0
  41. package/package.json +1 -1
  42. package/sbom.cdx.json +130 -130
  43. package/scripts/audit-cross-skill.js +1 -1
  44. package/scripts/bootstrap.js +1 -0
  45. package/scripts/build-indexes.js +84 -11
  46. package/scripts/check-agents-md-collectors.js +41 -13
  47. package/scripts/check-changelog-extract.js +4 -4
  48. package/scripts/check-codebase-patterns.js +19 -5
  49. package/scripts/check-manifest-snapshot.js +74 -30
  50. package/scripts/check-sbom-currency.js +25 -5
  51. package/scripts/check-test-count.js +26 -7
  52. package/scripts/check-test-coverage.js +44 -4
  53. package/scripts/check-version-tags.js +27 -8
  54. package/scripts/predeploy.js +1 -1
  55. package/scripts/refresh-manifest-snapshot.js +14 -4
  56. package/scripts/refresh-reverse-refs.js +7 -1
  57. package/scripts/refresh-sbom.js +1 -1
  58. package/scripts/release.js +3 -3
  59. package/scripts/run-e2e-scenarios.js +18 -8
  60. package/scripts/validate-vendor-online.js +28 -2
  61. package/scripts/verify-shipped-tarball.js +65 -6
  62. package/sources/validators/cve-validator.js +17 -1
  63. 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 alias for the catalog field name
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
- if (factors[f] === undefined || factors[f] === null) {
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 factors[f] !== 'boolean') {
125
- warnings.push(`${f}: expected boolean, got ${typeof factors[f]} (${JSON.stringify(factors[f])})`);
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
- if (factors.active_exploitation === undefined || factors.active_exploitation === null) {
198
+ const aeRaw = factors.active_exploitation;
199
+ if (aeRaw === undefined || aeRaw === null) {
130
200
  warnings.push("active_exploitation: missing (treated as 'none')");
131
- } else if (!aeAllowed.includes(factors.active_exploitation)) {
132
- warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
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 = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
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 values) {
289
- if (typeof v === 'number' && Number.isFinite(v)) sum += 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
- const delta = cvssAbsent ? null : rwep - cvssEquivalent;
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 ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
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 = ACTIVE_EXPLOITATION_LADDER[entry.active_exploitation] ?? 0;
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: entry.patch_required_reboot
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 but does not fail keypair generation — generating the
56
- * key was the load-bearing step; ACL tightening is best-effort hardening
57
- * on top.
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
- fs.writeFileSync(PRIVATE_KEY_PATH, privateKey, { encoding: 'utf8', mode: 0o600 });
130
- fs.writeFileSync(PUBLIC_KEY_PATH, publicKey, { encoding: 'utf8', mode: 0o644 });
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,
@@ -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
- try {
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
- const r = await fetch(feed.url, { signal: ac.signal, headers: { 'User-Agent': 'exceptd-advisories-poller/0.13.1 (+https://exceptd.com)' } });
345
- clearTimeout(timer);
346
- if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
347
- return { ok: true, body: await r.text() };
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
  }
@@ -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
- res.on("data", (c) => chunks.push(c));
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
- res.on("data", (c) => chunks.push(c));
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" });
@@ -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("%40", "@").replace("%2F", "/")}`;
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,