@blamejs/exceptd-skills 0.12.11 → 0.12.15

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 (91) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/bin/exceptd.js +299 -48
  3. package/data/_indexes/_meta.json +49 -48
  4. package/data/_indexes/activity-feed.json +13 -5
  5. package/data/_indexes/catalog-summaries.json +51 -29
  6. package/data/_indexes/chains.json +3238 -3210
  7. package/data/_indexes/frequency.json +3 -0
  8. package/data/_indexes/jurisdiction-map.json +5 -3
  9. package/data/_indexes/section-offsets.json +712 -685
  10. package/data/_indexes/theater-fingerprints.json +1 -1
  11. package/data/_indexes/token-budget.json +355 -340
  12. package/data/atlas-ttps.json +144 -129
  13. package/data/attack-techniques.json +339 -0
  14. package/data/cve-catalog.json +515 -475
  15. package/data/cwe-catalog.json +1081 -759
  16. package/data/exploit-availability.json +63 -15
  17. package/data/framework-control-gaps.json +867 -843
  18. package/data/rfc-references.json +276 -276
  19. package/keys/EXPECTED_FINGERPRINT +1 -0
  20. package/lib/auto-discovery.js +21 -4
  21. package/lib/cross-ref-api.js +39 -6
  22. package/lib/cve-curation.js +505 -47
  23. package/lib/lint-skills.js +217 -15
  24. package/lib/playbook-runner.js +1224 -183
  25. package/lib/prefetch.js +121 -8
  26. package/lib/refresh-external.js +261 -95
  27. package/lib/refresh-network.js +208 -18
  28. package/lib/schemas/manifest.schema.json +16 -0
  29. package/lib/scoring.js +83 -7
  30. package/lib/sign.js +112 -3
  31. package/lib/source-ghsa.js +219 -37
  32. package/lib/source-osv.js +381 -122
  33. package/lib/validate-catalog-meta.js +64 -9
  34. package/lib/validate-cve-catalog.js +213 -7
  35. package/lib/validate-indexes.js +88 -37
  36. package/lib/validate-playbooks.js +469 -0
  37. package/lib/verify.js +313 -16
  38. package/manifest-snapshot.json +1 -1
  39. package/manifest-snapshot.sha256 +1 -0
  40. package/manifest.json +73 -73
  41. package/orchestrator/dispatcher.js +21 -1
  42. package/orchestrator/event-bus.js +52 -8
  43. package/orchestrator/index.js +279 -20
  44. package/orchestrator/pipeline.js +63 -2
  45. package/orchestrator/scanner.js +32 -10
  46. package/orchestrator/scheduler.js +196 -20
  47. package/package.json +3 -1
  48. package/sbom.cdx.json +9 -9
  49. package/scripts/check-manifest-snapshot.js +32 -0
  50. package/scripts/check-sbom-currency.js +65 -3
  51. package/scripts/check-test-coverage.js +142 -19
  52. package/scripts/predeploy.js +110 -40
  53. package/scripts/refresh-manifest-snapshot.js +55 -4
  54. package/scripts/validate-vendor-online.js +169 -0
  55. package/scripts/verify-shipped-tarball.js +106 -3
  56. package/skills/ai-attack-surface/skill.md +18 -10
  57. package/skills/ai-c2-detection/skill.md +7 -2
  58. package/skills/ai-risk-management/skill.md +5 -4
  59. package/skills/api-security/skill.md +3 -3
  60. package/skills/attack-surface-pentest/skill.md +5 -5
  61. package/skills/cloud-security/skill.md +1 -1
  62. package/skills/compliance-theater/skill.md +8 -8
  63. package/skills/container-runtime-security/skill.md +1 -1
  64. package/skills/dlp-gap-analysis/skill.md +5 -1
  65. package/skills/email-security-anti-phishing/skill.md +1 -1
  66. package/skills/exploit-scoring/skill.md +18 -18
  67. package/skills/framework-gap-analysis/skill.md +6 -6
  68. package/skills/global-grc/skill.md +3 -2
  69. package/skills/identity-assurance/skill.md +2 -2
  70. package/skills/incident-response-playbook/skill.md +4 -4
  71. package/skills/kernel-lpe-triage/skill.md +21 -2
  72. package/skills/mcp-agent-trust/skill.md +17 -10
  73. package/skills/mlops-security/skill.md +2 -1
  74. package/skills/ot-ics-security/skill.md +1 -1
  75. package/skills/policy-exception-gen/skill.md +3 -3
  76. package/skills/pqc-first/skill.md +1 -1
  77. package/skills/rag-pipeline-security/skill.md +7 -3
  78. package/skills/researcher/skill.md +20 -3
  79. package/skills/sector-energy/skill.md +1 -1
  80. package/skills/sector-federal-government/skill.md +1 -1
  81. package/skills/sector-financial/skill.md +3 -3
  82. package/skills/sector-healthcare/skill.md +2 -2
  83. package/skills/security-maturity-tiers/skill.md +7 -7
  84. package/skills/skill-update-loop/skill.md +19 -3
  85. package/skills/supply-chain-integrity/skill.md +1 -1
  86. package/skills/threat-model-currency/skill.md +11 -11
  87. package/skills/threat-modeling-methodology/skill.md +3 -3
  88. package/skills/webapp-security/skill.md +1 -1
  89. package/skills/zeroday-gap-learn/skill.md +51 -7
  90. package/vendor/blamejs/_PROVENANCE.json +4 -1
  91. package/vendor/blamejs/worker-pool.js +38 -0
@@ -43,6 +43,7 @@ const process = require('node:process');
43
43
 
44
44
  const REPO_ROOT = path.resolve(__dirname, '..');
45
45
  const MANIFEST_PATH = path.join(REPO_ROOT, 'manifest.json');
46
+ const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
46
47
  const DATA_DIR = path.join(REPO_ROOT, 'data');
47
48
  const ATLAS_PATH = path.join(DATA_DIR, 'atlas-ttps.json');
48
49
  const FRAMEWORK_GAPS_PATH = path.join(DATA_DIR, 'framework-control-gaps.json');
@@ -50,6 +51,7 @@ const RFC_REFS_PATH = path.join(DATA_DIR, 'rfc-references.json');
50
51
  const CWE_REFS_PATH = path.join(DATA_DIR, 'cwe-catalog.json');
51
52
  const D3FEND_REFS_PATH = path.join(DATA_DIR, 'd3fend-catalog.json');
52
53
  const DLP_REFS_PATH = path.join(DATA_DIR, 'dlp-controls.json');
54
+ const ATTACK_REFS_PATH = path.join(DATA_DIR, 'attack-techniques.json');
53
55
 
54
56
  const REQUIRED_FRONTMATTER_FIELDS = [
55
57
  'name',
@@ -80,6 +82,18 @@ const REQUIRED_SECTIONS = [
80
82
  'Compliance Theater Check',
81
83
  ];
82
84
 
85
+ // L3 — Defensive Countermeasure Mapping became a required section for skills
86
+ // reviewed on or after this cutoff (documented in AGENTS.md). Pre-cutoff
87
+ // skills remain exempt to preserve patch-class compatibility; v0.13.0 may
88
+ // broaden the cutoff.
89
+ const COUNTERMEASURE_SECTION = 'Defensive Countermeasure Mapping';
90
+ const COUNTERMEASURE_CUTOFF = '2026-05-11';
91
+
92
+ // L1 — Minimum number of words of body text between a section heading and the
93
+ // next heading (or EOF) for the section to count as populated. Header-only
94
+ // sections surface as WARNINGS in v0.12.12; v0.13.0 will tighten to failure.
95
+ const MIN_SECTION_BODY_WORDS = 20;
96
+
83
97
  const PLACEHOLDER_PATTERNS = [
84
98
  /\bTODO\b/i,
85
99
  /\bTBD\b/i,
@@ -148,6 +162,12 @@ function readJson(p) {
148
162
  function parseFrontmatter(text) {
149
163
  const lines = text.split(/\r?\n/);
150
164
  const result = {};
165
+ // S4: track every top-level key we've already assigned. YAML's
166
+ // last-wins semantics would let a tampered skill set name twice
167
+ // ("name: real-skill\nname: evil-skill") and silently take the
168
+ // second value — a skill-identity spoofing primitive. Refuse
169
+ // duplicates outright; an honest skill never has them.
170
+ const seenKeys = new Set();
151
171
  let i = 0;
152
172
  while (i < lines.length) {
153
173
  const raw = lines[i];
@@ -166,6 +186,12 @@ function parseFrontmatter(text) {
166
186
  }
167
187
  const key = m[1];
168
188
  const rest = m[2];
189
+ if (seenKeys.has(key)) {
190
+ throw new Error(
191
+ `Duplicate frontmatter key "${key}" at line ${i + 1} — refusing last-wins semantics`,
192
+ );
193
+ }
194
+ seenKeys.add(key);
169
195
  if (rest === '' || rest === undefined) {
170
196
  const items = [];
171
197
  i++;
@@ -331,15 +357,69 @@ function validateFrontmatter(fm, skillName) {
331
357
  return errors;
332
358
  }
333
359
 
334
- function findMissingSections(body) {
335
- const lower = body.toLowerCase();
360
+ /* L1 — Heading-anchored section detection.
361
+ *
362
+ * Returns { missing, headerOnly }:
363
+ * - missing[] — sections with no `^## <Section Name>` heading anywhere
364
+ * in the body (case-insensitive). Hard failure.
365
+ * - headerOnly[] — sections whose heading exists but whose body between
366
+ * that heading and the next heading is shorter than
367
+ * MIN_SECTION_BODY_WORDS words. Warning in v0.12.12;
368
+ * v0.13.0 will tighten. */
369
+ function findMissingSections(body, requiredSections) {
370
+ const sections = requiredSections || REQUIRED_SECTIONS;
371
+ const lines = body.split(/\r?\n/);
372
+ // Index every heading line (any depth) so we know where each section ends.
373
+ const headings = [];
374
+ for (let i = 0; i < lines.length; i++) {
375
+ const m = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
376
+ if (m) {
377
+ headings.push({ line: i, depth: m[1].length, title: m[2].trim() });
378
+ }
379
+ }
380
+ // Heading match is case-insensitive and tolerates trailing context
381
+ // qualifiers (e.g. "## Threat Context (mid-2026)" or
382
+ // "## TTP Mapping (MITRE ATT&CK Enterprise, mid-2026)"). The required
383
+ // section name must appear as a leading token followed by end-of-string
384
+ // or a non-alphanumeric character (paren, dash, colon).
385
+ const findHeading = (title) => {
386
+ const t = title.toLowerCase();
387
+ return headings.find((h) => {
388
+ const lower = h.title.toLowerCase();
389
+ if (lower === t) return true;
390
+ if (lower.startsWith(t)) {
391
+ const next = lower[t.length];
392
+ if (next === undefined) return true;
393
+ if (!/[a-z0-9]/.test(next)) return true;
394
+ }
395
+ return false;
396
+ });
397
+ };
398
+
336
399
  const missing = [];
337
- for (const section of REQUIRED_SECTIONS) {
338
- if (!lower.includes(section.toLowerCase())) {
400
+ const headerOnly = [];
401
+ for (const section of sections) {
402
+ const h = findHeading(section);
403
+ if (!h) {
339
404
  missing.push(section);
405
+ continue;
406
+ }
407
+ // Find the next heading at the same or shallower depth, or EOF.
408
+ const idx = headings.indexOf(h);
409
+ let endLine = lines.length;
410
+ for (let j = idx + 1; j < headings.length; j++) {
411
+ if (headings[j].depth <= h.depth) {
412
+ endLine = headings[j].line;
413
+ break;
414
+ }
415
+ }
416
+ const bodyText = lines.slice(h.line + 1, endLine).join(' ').trim();
417
+ const wordCount = bodyText ? bodyText.split(/\s+/).length : 0;
418
+ if (wordCount < MIN_SECTION_BODY_WORDS) {
419
+ headerOnly.push({ section, wordCount });
340
420
  }
341
421
  }
342
- return missing;
422
+ return { missing, headerOnly };
343
423
  }
344
424
 
345
425
  function findPlaceholders(text) {
@@ -358,17 +438,18 @@ function findPlaceholders(text) {
358
438
 
359
439
  function lintSkill(entry, ctx) {
360
440
  const skillErrors = [];
441
+ const skillWarnings = [];
361
442
  const skillPath = path.join(REPO_ROOT, entry.path);
362
443
 
363
444
  if (!fs.existsSync(skillPath)) {
364
- return { name: entry.name, errors: [`skill file not found at ${entry.path}`] };
445
+ return { name: entry.name, errors: [`skill file not found at ${entry.path}`], warnings: [] };
365
446
  }
366
447
 
367
448
  const content = fs.readFileSync(skillPath, 'utf8');
368
449
  const { frontmatter: fmRaw, body } = extractFrontmatterBlock(content);
369
450
  if (fmRaw === null) {
370
451
  skillErrors.push('skill.md does not start with a `---` YAML frontmatter block');
371
- return { name: entry.name, errors: skillErrors };
452
+ return { name: entry.name, errors: skillErrors, warnings: skillWarnings };
372
453
  }
373
454
 
374
455
  let fm;
@@ -376,7 +457,7 @@ function lintSkill(entry, ctx) {
376
457
  fm = parseFrontmatter(fmRaw);
377
458
  } catch (err) {
378
459
  skillErrors.push(`frontmatter parse error: ${err.message}`);
379
- return { name: entry.name, errors: skillErrors };
460
+ return { name: entry.name, errors: skillErrors, warnings: skillWarnings };
380
461
  }
381
462
 
382
463
  skillErrors.push(...validateFrontmatter(fm, entry.name));
@@ -448,17 +529,62 @@ function lintSkill(entry, ctx) {
448
529
  }
449
530
  }
450
531
 
451
- const missingSections = findMissingSections(body);
452
- for (const s of missingSections) {
532
+ // L2 attack_refs cross-catalog resolution. Surface as WARNINGS in
533
+ // v0.12.12 to preserve patch-class compatibility; v0.13.0 will flip to
534
+ // hard failures. If data/attack-techniques.json is missing entirely the
535
+ // ctx.attackKeys set is null — skip the check (the gate degrades to its
536
+ // pre-v0.12.12 behavior).
537
+ if (Array.isArray(fm.attack_refs) && ctx.attackKeys) {
538
+ for (const ref of fm.attack_refs) {
539
+ if (!ctx.attackKeys.has(ref)) {
540
+ skillWarnings.push(
541
+ `attack_refs: "${ref}" not present in data/attack-techniques.json (will hard-fail in v0.13.0)`,
542
+ );
543
+ }
544
+ }
545
+ }
546
+
547
+ // L3 — Defensive Countermeasure Mapping is required for skills reviewed
548
+ // on or after COUNTERMEASURE_CUTOFF. Pre-cutoff skills are exempt. The
549
+ // section's absence on a post-cutoff skill is a WARNING in v0.12.12 so
550
+ // existing skills can add the section gradually; v0.13.0 will flip to
551
+ // a hard failure.
552
+ const { missing, headerOnly } = findMissingSections(body, REQUIRED_SECTIONS);
553
+ for (const s of missing) {
453
554
  skillErrors.push(`body: missing required section "${s}"`);
454
555
  }
556
+ for (const ho of headerOnly) {
557
+ // L1 — Header-only sections are WARNINGS in v0.12.12; v0.13.0 will
558
+ // tighten to failure.
559
+ skillWarnings.push(
560
+ `body: section "${ho.section}" has only ${ho.wordCount} words of body text (need >= ${MIN_SECTION_BODY_WORDS}); will hard-fail in v0.13.0`,
561
+ );
562
+ }
563
+ if (
564
+ typeof fm.last_threat_review === 'string' &&
565
+ ISO_DATE_RE.test(fm.last_threat_review) &&
566
+ fm.last_threat_review >= COUNTERMEASURE_CUTOFF
567
+ ) {
568
+ const cmResult = findMissingSections(body, [COUNTERMEASURE_SECTION]);
569
+ if (cmResult.missing.length > 0) {
570
+ skillWarnings.push(
571
+ `body: missing required section "${COUNTERMEASURE_SECTION}" (required for skills with last_threat_review >= ${COUNTERMEASURE_CUTOFF}; will hard-fail in v0.13.0)`,
572
+ );
573
+ } else {
574
+ for (const ho of cmResult.headerOnly) {
575
+ skillWarnings.push(
576
+ `body: section "${ho.section}" has only ${ho.wordCount} words of body text (need >= ${MIN_SECTION_BODY_WORDS}); will hard-fail in v0.13.0`,
577
+ );
578
+ }
579
+ }
580
+ }
455
581
 
456
582
  const placeholders = findPlaceholders(content);
457
583
  for (const p of placeholders) {
458
584
  skillErrors.push(`placeholder language at line ${p.line} (pattern /${p.pattern}/): ${p.text}`);
459
585
  }
460
586
 
461
- return { name: entry.name, errors: skillErrors };
587
+ return { name: entry.name, errors: skillErrors, warnings: skillWarnings };
462
588
  }
463
589
 
464
590
  function loadContext() {
@@ -475,6 +601,14 @@ function loadContext() {
475
601
  }
476
602
  return s;
477
603
  }
604
+ // L2 — attack-techniques.json may not exist in older trees. When absent,
605
+ // ctx.attackKeys is null and the L2 check is skipped.
606
+ let attackKeys = null;
607
+ if (fs.existsSync(ATTACK_REFS_PATH)) {
608
+ attackKeys = new Set();
609
+ const j = readJson(ATTACK_REFS_PATH);
610
+ for (const k of Object.keys(j)) if (!k.startsWith('_')) attackKeys.add(k);
611
+ }
478
612
  return {
479
613
  atlasKeys,
480
614
  frameworkKeys,
@@ -482,9 +616,45 @@ function loadContext() {
482
616
  cweKeys: loadKeys(CWE_REFS_PATH),
483
617
  d3fendKeys: loadKeys(D3FEND_REFS_PATH),
484
618
  dlpKeys: loadKeys(DLP_REFS_PATH),
619
+ attackKeys,
485
620
  };
486
621
  }
487
622
 
623
+ /*
624
+ * S6 — orphan skill.md detector.
625
+ *
626
+ * Walk every subdirectory of skills/ and assert each skill.md file is
627
+ * referenced by exactly one manifest entry. Catches the v0.12.8
628
+ * stash-restore class: a directory left behind on disk that nobody
629
+ * signs because nobody listed it in the manifest, then the next
630
+ * `npm pack` ships an unsigned skill (or worse, conflicts with a
631
+ * future manifest entry of the same name).
632
+ *
633
+ * @param {Array<{path: string}>} manifestSkills
634
+ * @returns {string[]} list of orphan filesystem paths (relative)
635
+ */
636
+ function findOrphanSkillFiles(manifestSkills) {
637
+ if (!fs.existsSync(SKILLS_DIR)) return [];
638
+ // F19 — manifest paths are stored as forward-slash strings by contract
639
+ // (lib/verify.js validateSkillPath() rejects backslashes). The previous
640
+ // path.sep split was a no-op on Linux and incorrect on Windows when
641
+ // mixed separators arrived through other ingest paths; the cleaner
642
+ // contract is to normalise the comparison key directly.
643
+ const referenced = new Set(
644
+ manifestSkills.map((s) => String(s.path).replace(/\\/g, '/')),
645
+ );
646
+ const orphans = [];
647
+ for (const entry of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
648
+ if (!entry.isDirectory()) continue;
649
+ const candidate = path.join(SKILLS_DIR, entry.name, 'skill.md');
650
+ if (fs.existsSync(candidate)) {
651
+ const rel = `skills/${entry.name}/skill.md`;
652
+ if (!referenced.has(rel)) orphans.push(rel);
653
+ }
654
+ }
655
+ return orphans;
656
+ }
657
+
488
658
  function main() {
489
659
  const opts = parseArgs(process.argv);
490
660
  const manifest = readJson(MANIFEST_PATH);
@@ -503,24 +673,50 @@ function main() {
503
673
  const results = skills.map((entry) => lintSkill(entry, ctx));
504
674
 
505
675
  let failed = 0;
676
+ let warned = 0;
506
677
  for (const r of results) {
507
- if (r.errors.length === 0) {
678
+ const warns = r.warnings || [];
679
+ if (r.errors.length === 0 && warns.length === 0) {
508
680
  if (!opts.quiet) {
509
681
  console.log(`PASS ${r.name}`);
510
682
  }
683
+ } else if (r.errors.length === 0) {
684
+ warned++;
685
+ if (!opts.quiet) console.log(`WARN ${r.name}`);
686
+ for (const w of warns) console.log(` - [warn] ${w}`);
511
687
  } else {
512
688
  failed++;
513
689
  console.log(`FAIL ${r.name}`);
514
690
  for (const e of r.errors) {
515
691
  console.log(` - ${e}`);
516
692
  }
693
+ for (const w of warns) {
694
+ console.log(` - [warn] ${w}`);
695
+ }
696
+ }
697
+ }
698
+
699
+ // S6 — orphan check runs only on a full lint pass (no --skill filter).
700
+ // A targeted single-skill lint is for diagnosing one entry; running
701
+ // the orphan walk there would surface unrelated findings.
702
+ let orphans = [];
703
+ if (!opts.skill) {
704
+ orphans = findOrphanSkillFiles(manifest.skills);
705
+ for (const o of orphans) {
706
+ console.log(`FAIL <orphan>`);
707
+ console.log(` - skill.md exists on disk but not in manifest: ${o}`);
708
+ console.log(` fix: re-run \`node lib/sign.js sign-all\` after adding it to manifest.json, OR delete the orphan directory`);
517
709
  }
518
710
  }
519
711
 
520
712
  const total = results.length;
521
- const passed = total - failed;
522
- console.log(`\n${passed}/${total} skills passed${failed ? `, ${failed} failed` : ''}.`);
523
- process.exit(failed === 0 ? 0 : 1);
713
+ const passed = total - failed - warned;
714
+ const orphanSummary = orphans.length ? `, ${orphans.length} orphan skill.md file(s)` : '';
715
+ const warnSummary = warned ? `, ${warned} with warnings` : '';
716
+ console.log(
717
+ `\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}.`,
718
+ );
719
+ process.exit(failed === 0 && orphans.length === 0 ? 0 : 1);
524
720
  }
525
721
 
526
722
  // Export the minimal frontmatter parser for downstream consumers
@@ -529,6 +725,12 @@ module.exports = {
529
725
  parseFrontmatter,
530
726
  extractFrontmatterBlock,
531
727
  unquote,
728
+ findOrphanSkillFiles,
729
+ findMissingSections,
730
+ REQUIRED_SECTIONS,
731
+ COUNTERMEASURE_SECTION,
732
+ COUNTERMEASURE_CUTOFF,
733
+ MIN_SECTION_BODY_WORDS,
532
734
  };
533
735
 
534
736
  if (require.main === module) {