@blamejs/exceptd-skills 0.12.41 → 0.13.0

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 (79) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/bin/exceptd.js +52 -44
  3. package/data/_indexes/_meta.json +47 -47
  4. package/data/_indexes/chains.json +485 -13
  5. package/data/_indexes/jurisdiction-map.json +15 -4
  6. package/data/_indexes/section-offsets.json +1244 -1244
  7. package/data/_indexes/token-budget.json +173 -173
  8. package/data/atlas-ttps.json +54 -11
  9. package/data/attack-techniques.json +113 -17
  10. package/data/cve-catalog.json +17 -24
  11. package/data/cwe-catalog.json +8 -2
  12. package/data/framework-control-gaps.json +13 -3
  13. package/data/playbooks/ai-api.json +5 -0
  14. package/data/playbooks/cicd-pipeline-compromise.json +970 -0
  15. package/data/playbooks/cloud-iam-incident.json +4 -1
  16. package/data/playbooks/cred-stores.json +10 -0
  17. package/data/playbooks/framework.json +16 -0
  18. package/data/playbooks/hardening.json +4 -0
  19. package/data/playbooks/identity-sso-compromise.json +951 -0
  20. package/data/playbooks/idp-incident.json +3 -0
  21. package/data/playbooks/kernel.json +6 -0
  22. package/data/playbooks/llm-tool-use-exfil.json +963 -0
  23. package/data/playbooks/mcp.json +6 -0
  24. package/data/playbooks/runtime.json +4 -0
  25. package/data/playbooks/sbom.json +13 -0
  26. package/data/playbooks/secrets.json +6 -0
  27. package/data/playbooks/webhook-callback-abuse.json +916 -0
  28. package/lib/cross-ref-api.js +33 -13
  29. package/lib/cve-curation.js +12 -1
  30. package/lib/exit-codes.js +29 -0
  31. package/lib/lint-skills.js +24 -2
  32. package/lib/refresh-external.js +10 -1
  33. package/lib/scoring.js +55 -0
  34. package/manifest.json +83 -83
  35. package/orchestrator/index.js +32 -24
  36. package/package.json +1 -1
  37. package/sbom.cdx.json +122 -78
  38. package/scripts/predeploy.js +7 -13
  39. package/scripts/refresh-reverse-refs.js +86 -0
  40. package/scripts/refresh-sbom.js +21 -4
  41. package/skills/age-gates-child-safety/skill.md +1 -5
  42. package/skills/ai-attack-surface/skill.md +11 -4
  43. package/skills/ai-c2-detection/skill.md +11 -2
  44. package/skills/ai-risk-management/skill.md +4 -2
  45. package/skills/api-security/skill.md +7 -8
  46. package/skills/attack-surface-pentest/skill.md +2 -2
  47. package/skills/cloud-iam-incident/skill.md +1 -5
  48. package/skills/cloud-security/skill.md +0 -4
  49. package/skills/compliance-theater/skill.md +10 -2
  50. package/skills/container-runtime-security/skill.md +1 -3
  51. package/skills/dlp-gap-analysis/skill.md +3 -4
  52. package/skills/email-security-anti-phishing/skill.md +1 -8
  53. package/skills/exploit-scoring/skill.md +7 -2
  54. package/skills/framework-gap-analysis/skill.md +1 -1
  55. package/skills/fuzz-testing-strategy/skill.md +1 -2
  56. package/skills/global-grc/skill.md +3 -2
  57. package/skills/identity-assurance/skill.md +1 -3
  58. package/skills/idp-incident-response/skill.md +1 -4
  59. package/skills/incident-response-playbook/skill.md +1 -5
  60. package/skills/kernel-lpe-triage/skill.md +2 -2
  61. package/skills/mcp-agent-trust/skill.md +13 -3
  62. package/skills/mlops-security/skill.md +2 -3
  63. package/skills/ot-ics-security/skill.md +0 -3
  64. package/skills/policy-exception-gen/skill.md +11 -3
  65. package/skills/pqc-first/skill.md +4 -2
  66. package/skills/rag-pipeline-security/skill.md +2 -0
  67. package/skills/ransomware-response/skill.md +1 -5
  68. package/skills/researcher/skill.md +4 -3
  69. package/skills/sector-energy/skill.md +0 -4
  70. package/skills/sector-federal-government/skill.md +2 -3
  71. package/skills/sector-financial/skill.md +1 -4
  72. package/skills/sector-healthcare/skill.md +0 -5
  73. package/skills/sector-telecom/skill.md +0 -4
  74. package/skills/security-maturity-tiers/skill.md +1 -2
  75. package/skills/skill-update-loop/skill.md +4 -3
  76. package/skills/supply-chain-integrity/skill.md +4 -3
  77. package/skills/threat-model-currency/skill.md +1 -1
  78. package/skills/threat-modeling-methodology/skill.md +2 -1
  79. package/skills/webapp-security/skill.md +0 -5
@@ -37,56 +37,76 @@ const _cache = new Map();
37
37
  // can inspect.
38
38
  const _loadErrors = [];
39
39
 
40
- function _statMtime(p) {
41
- try { return fs.statSync(p).mtimeMs; }
42
- catch { return null; }
40
+ /**
41
+ * v0.13.0: cache invalidation is keyed on (mtimeMs, size). Pre-v0.13 it
42
+ * was mtime-only, but on filesystems with 1-2s mtime granularity
43
+ * (FAT32, HFS+ pre-APFS, NFSv3, Docker bind-mounts that proxy mtime)
44
+ * a rapid refresh-then-reload within the same second served stale
45
+ * cached data. Adding `size` catches every content change that affects
46
+ * byte count; mtimeMs catches in-place rewrites that preserve byte
47
+ * count. Together they cover every realistic catalog-mutation path
48
+ * without the cost of a per-load SHA computation. SHA-based tier is
49
+ * available via _statContentHash() when callers want full invalidation
50
+ * (e.g. long-running daemons against append-only catalogs).
51
+ */
52
+ function _statSignature(p) {
53
+ try {
54
+ const s = fs.statSync(p);
55
+ return { mtime: s.mtimeMs, size: s.size };
56
+ } catch { return null; }
57
+ }
58
+
59
+ function _signatureEquals(a, b) {
60
+ if (a === null && b === null) return true;
61
+ if (a === null || b === null) return false;
62
+ return a.mtime === b.mtime && a.size === b.size;
43
63
  }
44
64
 
45
65
  function loadCatalog(filename) {
46
66
  const full = path.join(DATA_DIR, filename);
47
- const mtime = _statMtime(full);
67
+ const sig = _statSignature(full);
48
68
  const cached = _cache.get(filename);
49
- if (cached && (mtime === null || cached.mtime === mtime)) {
69
+ if (cached && (sig === null || _signatureEquals(cached.sig, sig))) {
50
70
  return cached.value;
51
71
  }
52
72
  if (!fs.existsSync(full)) {
53
- _cache.set(filename, { value: {}, mtime });
73
+ _cache.set(filename, { value: {}, sig });
54
74
  return {};
55
75
  }
56
76
  try {
57
77
  const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
58
- _cache.set(filename, { value: parsed, mtime });
78
+ _cache.set(filename, { value: parsed, sig });
59
79
  return parsed;
60
80
  } catch (e) {
61
81
  _loadErrors.push({ kind: 'catalog', file: filename, error: e.message });
62
82
  const stub = {};
63
83
  Object.defineProperty(stub, '_loadError', { value: e.message, enumerable: false });
64
- _cache.set(filename, { value: stub, mtime });
84
+ _cache.set(filename, { value: stub, sig });
65
85
  return stub;
66
86
  }
67
87
  }
68
88
 
69
89
  function loadIndex(filename) {
70
90
  const full = path.join(INDEX_DIR, filename);
71
- const mtime = _statMtime(full);
91
+ const sig = _statSignature(full);
72
92
  const key = 'idx:' + filename;
73
93
  const cached = _cache.get(key);
74
- if (cached && (mtime === null || cached.mtime === mtime)) {
94
+ if (cached && (sig === null || _signatureEquals(cached.sig, sig))) {
75
95
  return cached.value;
76
96
  }
77
97
  if (!fs.existsSync(full)) {
78
- _cache.set(key, { value: {}, mtime });
98
+ _cache.set(key, { value: {}, sig });
79
99
  return {};
80
100
  }
81
101
  try {
82
102
  const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
83
- _cache.set(key, { value: parsed, mtime });
103
+ _cache.set(key, { value: parsed, sig });
84
104
  return parsed;
85
105
  } catch (e) {
86
106
  _loadErrors.push({ kind: 'index', file: filename, error: e.message });
87
107
  const stub = {};
88
108
  Object.defineProperty(stub, '_loadError', { value: e.message, enumerable: false });
89
- _cache.set(key, { value: stub, mtime });
109
+ _cache.set(key, { value: stub, sig });
90
110
  return stub;
91
111
  }
92
112
  }
@@ -637,7 +637,18 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
637
637
 
638
638
  function writeJsonAtomic(p, obj) {
639
639
  const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
640
- fs.writeFileSync(tmpPath, JSON.stringify(obj, null, 2) + "\n", "utf8");
640
+ // v0.13.0: fsync the tmp file before rename so a power loss between
641
+ // write and rename leaves the durable destination intact. Without
642
+ // fsync the data sits in the OS page cache and the rename succeeds
643
+ // atomically, but the renamed file may be zero-length / partial on
644
+ // crash. Open + write + fsync + close + rename is the durable idiom.
645
+ const fd = fs.openSync(tmpPath, 'w');
646
+ try {
647
+ fs.writeSync(fd, JSON.stringify(obj, null, 2) + "\n", 0, "utf8");
648
+ fs.fsyncSync(fd);
649
+ } finally {
650
+ fs.closeSync(fd);
651
+ }
641
652
  try {
642
653
  fs.renameSync(tmpPath, p);
643
654
  } catch (err) {
package/lib/exit-codes.js CHANGED
@@ -66,9 +66,38 @@ function listExitCodes() {
66
66
  }));
67
67
  }
68
68
 
69
+ /**
70
+ * Set the process exit code WITHOUT calling process.exit(). Returns to the
71
+ * caller so any pending stdout/stderr writes drain on natural event-loop
72
+ * shutdown.
73
+ *
74
+ * `process.exit(N)` terminates the process synchronously, which truncates
75
+ * buffered async stdout when stdout is piped (CI, test harnesses, --json
76
+ * consumers). The v0.11.10 CI #100 fix established the exitCode-then-return
77
+ * idiom for that reason; every subsequent regression that re-introduced
78
+ * bare process.exit() in the dispatch surface was the same class of bug.
79
+ *
80
+ * Callers SHOULD prefer this helper for any exit-after-stdout-write path.
81
+ * Long-running daemons / tests that need synchronous termination can still
82
+ * use process.exit() directly — that's intentional and not what this guards.
83
+ *
84
+ * @param {number} code Exit code (use the EXIT_CODES constants)
85
+ * @returns {void}
86
+ */
87
+ function safeExit(code) {
88
+ // Only override exitCode when it isn't already set to a non-zero value —
89
+ // matches the emit() ok:false fallback contract so a caller that already
90
+ // set BLOCKED (4) before emit() doesn't get overwritten by a later
91
+ // GENERIC_FAILURE (1).
92
+ if (!process.exitCode || process.exitCode === 0) {
93
+ process.exitCode = code;
94
+ }
95
+ }
96
+
69
97
  module.exports = {
70
98
  EXIT_CODES,
71
99
  EXIT_CODE_DESCRIPTIONS,
72
100
  exitCodeName,
73
101
  listExitCodes,
102
+ safeExit,
74
103
  };
@@ -243,6 +243,11 @@ function extractFrontmatterBlock(content) {
243
243
  /* Validate frontmatter object against the codified schema rules. */
244
244
  function validateFrontmatter(fm, skillName) {
245
245
  const errors = [];
246
+ // v0.13.0: validateFrontmatter now ALSO surfaces warnings (e.g. the
247
+ // last_threat_review 180-day soft cap). Return signature changes from
248
+ // `string[]` to `{ errors: string[], warnings: string[] }` — callers
249
+ // updated accordingly.
250
+ const warnings = [];
246
251
 
247
252
  for (const key of Object.keys(fm)) {
248
253
  if (!ALL_KNOWN_FIELDS.has(key)) {
@@ -351,10 +356,25 @@ function validateFrontmatter(fm, skillName) {
351
356
  errors.push(
352
357
  `frontmatter.last_threat_review "${fm.last_threat_review}" is not an ISO date (YYYY-MM-DD)`,
353
358
  );
359
+ } else {
360
+ // v0.13.0: Hard Rule #8 forcing function — refuse skills whose
361
+ // last_threat_review is older than the staleness threshold.
362
+ // 180-day soft cap (warn), 365-day hard cap (fail). Operators on
363
+ // older releases who don't refresh fall off the supported window.
364
+ const days = Math.floor((Date.now() - Date.parse(fm.last_threat_review + 'T00:00:00Z')) / (24 * 60 * 60 * 1000));
365
+ if (days > 365) {
366
+ errors.push(
367
+ `frontmatter.last_threat_review "${fm.last_threat_review}" is ${days} days old — Hard Rule #8 staleness gate (hard fail at >365 days). Refresh the threat review against current intel and bump the date.`,
368
+ );
369
+ } else if (days > 180) {
370
+ warnings.push(
371
+ `frontmatter.last_threat_review "${fm.last_threat_review}" is ${days} days old — Hard Rule #8 staleness warning (warn at >180 days, hard fail at >365). Schedule a review.`,
372
+ );
373
+ }
354
374
  }
355
375
  }
356
376
 
357
- return errors;
377
+ return { errors, warnings };
358
378
  }
359
379
 
360
380
  /* L1 — Heading-anchored section detection.
@@ -460,7 +480,9 @@ function lintSkill(entry, ctx) {
460
480
  return { name: entry.name, errors: skillErrors, warnings: skillWarnings };
461
481
  }
462
482
 
463
- skillErrors.push(...validateFrontmatter(fm, entry.name));
483
+ const fmResult = validateFrontmatter(fm, entry.name);
484
+ skillErrors.push(...fmResult.errors);
485
+ skillWarnings.push(...fmResult.warnings);
464
486
 
465
487
  if (Array.isArray(fm.data_deps)) {
466
488
  for (const dep of fm.data_deps) {
@@ -1066,7 +1066,16 @@ function loadCtx(opts) {
1066
1066
  // worker threads) never collide on the same scratch path.
1067
1067
  function writeJsonAtomic(p, obj) {
1068
1068
  const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
1069
- fs.writeFileSync(tmpPath, JSON.stringify(obj, null, 2) + "\n", "utf8");
1069
+ // v0.13.0: fsync the tmp file before rename so a power loss between
1070
+ // write and rename leaves the durable destination intact. See the
1071
+ // matching helper in lib/cve-curation.js for the rationale.
1072
+ const fd = fs.openSync(tmpPath, 'w');
1073
+ try {
1074
+ fs.writeSync(fd, JSON.stringify(obj, null, 2) + "\n", 0, "utf8");
1075
+ fs.fsyncSync(fd);
1076
+ } finally {
1077
+ fs.closeSync(fd);
1078
+ }
1070
1079
  try {
1071
1080
  fs.renameSync(tmpPath, p);
1072
1081
  } catch (err) {
package/lib/scoring.js CHANGED
@@ -346,6 +346,54 @@ function compare(cveId, catalog, opts) {
346
346
  return out;
347
347
  }
348
348
 
349
+ /**
350
+ * v0.13.0: detect rwep_factors shape. The catalog historically stored
351
+ * factors in two distinct shapes that look identical at the field level:
352
+ *
353
+ * Shape A (raw): `{ cisa_kev: true, blast_radius: 30, ... }`
354
+ * - booleans + integers in their natural form
355
+ * - score derives from `scoreCustom(factors)` which applies weights
356
+ *
357
+ * Shape B (post-weight): `{ cisa_kev: 25, blast_radius: 30, ... }`
358
+ * - integers in their post-weight contribution (cisa_kev: 25 not true)
359
+ * - score = sum of values; no second weight pass
360
+ *
361
+ * Mixing shapes inside ONE entry silently breaks the sum invariant —
362
+ * a CVE with `cisa_kev: true, blast_radius: 30` reports rwep 30 (just
363
+ * blast_radius summed) when the operator-intended score is 55 (KEV + br).
364
+ * Until v0.13 nothing caught this; v0.13 adds shape detection that fires
365
+ * an error when the entry mixes booleans with non-trivial numeric weights.
366
+ *
367
+ * Returns 'A' for raw, 'B' for post-weight, 'unknown' for empty/edge
368
+ * cases, or 'mixed' for the violating case.
369
+ */
370
+ function detectFactorShape(factors) {
371
+ if (!factors || typeof factors !== 'object') return 'unknown';
372
+ const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weaponization', 'ai_discovered', 'active_exploitation', 'patch_available', 'live_patch_available', 'patch_required_reboot'];
373
+ let sawBool = false;
374
+ let sawWeightedInt = false;
375
+ for (const [k, v] of Object.entries(factors)) {
376
+ if (k === 'blast_radius') continue; // always integer in both shapes
377
+ if (typeof v === 'boolean' || v === null) {
378
+ sawBool = true;
379
+ } else if (typeof v === 'number' && Math.abs(v) >= 5 && boolFields.includes(k)) {
380
+ // Field that's nominally boolean carrying a numeric weight (e.g. 25,
381
+ // 20, 15) — Shape B signature.
382
+ sawWeightedInt = true;
383
+ } else if (typeof v === 'number' && (v === 0 || v === 1) && boolFields.includes(k)) {
384
+ // 0/1 on a boolean-named field could be either shape; ambiguous, ignore.
385
+ continue;
386
+ } else if (typeof v === 'string' && boolFields.includes(k)) {
387
+ // String values (e.g. active_exploitation: 'confirmed') are Shape A.
388
+ sawBool = true;
389
+ }
390
+ }
391
+ if (sawBool && sawWeightedInt) return 'mixed';
392
+ if (sawWeightedInt) return 'B';
393
+ if (sawBool) return 'A';
394
+ return 'unknown';
395
+ }
396
+
349
397
  function validate(catalog) {
350
398
  const errors = [];
351
399
  for (const [cveId, entry] of Object.entries(catalog)) {
@@ -371,6 +419,13 @@ function validate(catalog) {
371
419
  if (entry.live_patch_available && (!entry.live_patch_tools || entry.live_patch_tools.length === 0)) {
372
420
  errors.push(`${cveId}: live_patch_available=true but live_patch_tools is empty`);
373
421
  }
422
+ // v0.13.0: detect Shape A / Shape B / mixed factor shape. A 'mixed'
423
+ // shape would silently break the sum invariant; refuse it. See
424
+ // detectFactorShape() doc above for the failure mode.
425
+ const shape = detectFactorShape(entry.rwep_factors);
426
+ if (shape === 'mixed') {
427
+ errors.push(`${cveId}: rwep_factors mixes Shape A (booleans) with Shape B (post-weight integers) — sum invariant cannot hold. Convert factors to a single shape.`);
428
+ }
374
429
  const calculatedRwep = scoreCustom({
375
430
  cisa_kev: entry.cisa_kev,
376
431
  poc_available: entry.poc_available,