@blamejs/exceptd-skills 0.12.10 → 0.12.13
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 +131 -0
- package/README.md +3 -1
- package/bin/exceptd.js +152 -39
- package/data/_indexes/_meta.json +10 -9
- package/data/_indexes/activity-feed.json +11 -3
- package/data/_indexes/catalog-summaries.json +24 -2
- package/data/_indexes/frequency.json +2 -0
- package/data/attack-techniques.json +96 -0
- package/data/cve-catalog.json +9 -9
- package/data/cwe-catalog.json +4 -3
- package/data/framework-control-gaps.json +52 -0
- package/data/playbooks/library-author.json +3 -3
- package/lib/cve-curation.js +491 -46
- package/lib/lint-skills.js +212 -15
- package/lib/playbook-runner.js +485 -108
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +257 -81
- package/lib/refresh-network.js +15 -1
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +68 -5
- package/lib/sign.js +112 -3
- package/lib/source-ghsa.js +7 -1
- package/lib/source-osv.js +228 -57
- package/lib/validate-cve-catalog.js +171 -3
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +241 -16
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/scheduler.js +50 -7
- package/package.json +1 -1
- package/sbom.cdx.json +8 -8
- package/scripts/predeploy.js +31 -5
package/lib/lint-skills.js
CHANGED
|
@@ -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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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,40 @@ 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
|
+
const referenced = new Set(
|
|
639
|
+
manifestSkills.map((s) => s.path.split(path.sep).join('/')),
|
|
640
|
+
);
|
|
641
|
+
const orphans = [];
|
|
642
|
+
for (const entry of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
|
|
643
|
+
if (!entry.isDirectory()) continue;
|
|
644
|
+
const candidate = path.join(SKILLS_DIR, entry.name, 'skill.md');
|
|
645
|
+
if (fs.existsSync(candidate)) {
|
|
646
|
+
const rel = `skills/${entry.name}/skill.md`;
|
|
647
|
+
if (!referenced.has(rel)) orphans.push(rel);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return orphans;
|
|
651
|
+
}
|
|
652
|
+
|
|
488
653
|
function main() {
|
|
489
654
|
const opts = parseArgs(process.argv);
|
|
490
655
|
const manifest = readJson(MANIFEST_PATH);
|
|
@@ -503,24 +668,50 @@ function main() {
|
|
|
503
668
|
const results = skills.map((entry) => lintSkill(entry, ctx));
|
|
504
669
|
|
|
505
670
|
let failed = 0;
|
|
671
|
+
let warned = 0;
|
|
506
672
|
for (const r of results) {
|
|
507
|
-
|
|
673
|
+
const warns = r.warnings || [];
|
|
674
|
+
if (r.errors.length === 0 && warns.length === 0) {
|
|
508
675
|
if (!opts.quiet) {
|
|
509
676
|
console.log(`PASS ${r.name}`);
|
|
510
677
|
}
|
|
678
|
+
} else if (r.errors.length === 0) {
|
|
679
|
+
warned++;
|
|
680
|
+
if (!opts.quiet) console.log(`WARN ${r.name}`);
|
|
681
|
+
for (const w of warns) console.log(` - [warn] ${w}`);
|
|
511
682
|
} else {
|
|
512
683
|
failed++;
|
|
513
684
|
console.log(`FAIL ${r.name}`);
|
|
514
685
|
for (const e of r.errors) {
|
|
515
686
|
console.log(` - ${e}`);
|
|
516
687
|
}
|
|
688
|
+
for (const w of warns) {
|
|
689
|
+
console.log(` - [warn] ${w}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// S6 — orphan check runs only on a full lint pass (no --skill filter).
|
|
695
|
+
// A targeted single-skill lint is for diagnosing one entry; running
|
|
696
|
+
// the orphan walk there would surface unrelated findings.
|
|
697
|
+
let orphans = [];
|
|
698
|
+
if (!opts.skill) {
|
|
699
|
+
orphans = findOrphanSkillFiles(manifest.skills);
|
|
700
|
+
for (const o of orphans) {
|
|
701
|
+
console.log(`FAIL <orphan>`);
|
|
702
|
+
console.log(` - skill.md exists on disk but not in manifest: ${o}`);
|
|
703
|
+
console.log(` fix: re-run \`node lib/sign.js sign-all\` after adding it to manifest.json, OR delete the orphan directory`);
|
|
517
704
|
}
|
|
518
705
|
}
|
|
519
706
|
|
|
520
707
|
const total = results.length;
|
|
521
|
-
const passed = total - failed;
|
|
522
|
-
|
|
523
|
-
|
|
708
|
+
const passed = total - failed - warned;
|
|
709
|
+
const orphanSummary = orphans.length ? `, ${orphans.length} orphan skill.md file(s)` : '';
|
|
710
|
+
const warnSummary = warned ? `, ${warned} with warnings` : '';
|
|
711
|
+
console.log(
|
|
712
|
+
`\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}.`,
|
|
713
|
+
);
|
|
714
|
+
process.exit(failed === 0 && orphans.length === 0 ? 0 : 1);
|
|
524
715
|
}
|
|
525
716
|
|
|
526
717
|
// Export the minimal frontmatter parser for downstream consumers
|
|
@@ -529,6 +720,12 @@ module.exports = {
|
|
|
529
720
|
parseFrontmatter,
|
|
530
721
|
extractFrontmatterBlock,
|
|
531
722
|
unquote,
|
|
723
|
+
findOrphanSkillFiles,
|
|
724
|
+
findMissingSections,
|
|
725
|
+
REQUIRED_SECTIONS,
|
|
726
|
+
COUNTERMEASURE_SECTION,
|
|
727
|
+
COUNTERMEASURE_CUTOFF,
|
|
728
|
+
MIN_SECTION_BODY_WORDS,
|
|
532
729
|
};
|
|
533
730
|
|
|
534
731
|
if (require.main === module) {
|