@blamejs/exceptd-skills 0.19.32 → 0.19.34
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 +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +2 -2
- package/sbom.cdx.json +253 -253
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
package/lib/lint-skills.js
CHANGED
|
@@ -1,38 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Enforces AGENTS.md rules that are otherwise informal:
|
|
6
|
-
* Rule #10 — No placeholder data. Skill bodies and frontmatter must not
|
|
7
|
-
* contain TODO / TBD / coming soon / placeholder / fixme / XXX
|
|
8
|
-
* / to be determined.
|
|
9
|
-
* Rule #11 — No-MVP ban. Every skill ships with complete frontmatter,
|
|
10
|
-
* all 7 required body sections, all data deps existing, and
|
|
11
|
-
* all referenced TTPs / framework controls resolving.
|
|
12
|
-
*
|
|
13
|
-
* For every skill registered in manifest.json this linter checks:
|
|
14
|
-
* - skill.md exists at the manifest path
|
|
15
|
-
* - frontmatter contains every required field per AGENTS.md spec
|
|
16
|
-
* - frontmatter values conform to lib/schemas/skill-frontmatter.schema.json
|
|
17
|
-
* (the subset relevant for this codebase — no external validator dep)
|
|
18
|
-
* - body contains all 7 required H2/H3 sections (case-insensitive):
|
|
19
|
-
* Threat Context, Framework Lag Declaration, TTP Mapping,
|
|
20
|
-
* Exploit Availability Matrix, Analysis Procedure, Output Format,
|
|
21
|
-
* Compliance Theater Check
|
|
22
|
-
* - body and frontmatter free of placeholder language
|
|
23
|
-
* - every data_deps filename resolves to data/<filename>
|
|
24
|
-
* - every atlas_refs ID exists as a top-level key in data/atlas-ttps.json
|
|
25
|
-
* - every framework_gaps ID exists as a top-level key in
|
|
26
|
-
* data/framework-control-gaps.json
|
|
27
|
-
*
|
|
28
|
-
* Usage:
|
|
29
|
-
* node lib/lint-skills.js lint every skill
|
|
30
|
-
* node lib/lint-skills.js --skill foo lint only the named skill
|
|
31
|
-
* node lib/lint-skills.js --quiet only print failures and final summary
|
|
32
|
-
*
|
|
33
|
-
* Exit code: 0 if every linted skill passes, 1 otherwise.
|
|
34
|
-
*
|
|
35
|
-
* No external dependencies. Node 24 stdlib only.
|
|
3
|
+
* Pre-ship linter for the skills registered in manifest.json. Exit 0 when every
|
|
4
|
+
* linted skill passes, 1 otherwise.
|
|
36
5
|
*/
|
|
37
6
|
|
|
38
7
|
'use strict';
|
|
@@ -69,12 +38,9 @@ const REQUIRED_FRONTMATTER_FIELDS = [
|
|
|
69
38
|
'last_threat_review',
|
|
70
39
|
];
|
|
71
40
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// `exceptd brief <name>` or `exceptd ask` routing and is NOT chained — this
|
|
76
|
-
// closes the v0.12 audit gap that 16 skills had no playbook chain pointing
|
|
77
|
-
// at them. Operator intent now explicit; not a sign of orphan-skill drift.
|
|
41
|
+
// `discovery_mode` records how operators reach the skill: omitted means a
|
|
42
|
+
// playbook's direct.skill_chain references it; `standalone` means it is reached
|
|
43
|
+
// through `exceptd brief <name>` or `ask` routing, so no chain is deliberate.
|
|
78
44
|
const OPTIONAL_FRONTMATTER_FIELDS = ['forward_watch', 'rfc_refs', 'cwe_refs', 'd3fend_refs', 'dlp_refs', 'discovery_mode'];
|
|
79
45
|
|
|
80
46
|
const ALL_KNOWN_FIELDS = new Set([
|
|
@@ -92,15 +58,12 @@ const REQUIRED_SECTIONS = [
|
|
|
92
58
|
'Compliance Theater Check',
|
|
93
59
|
];
|
|
94
60
|
|
|
95
|
-
//
|
|
96
|
-
// reviewed on or after this cutoff (documented in AGENTS.md). Pre-cutoff
|
|
97
|
-
// skills remain exempt to preserve patch-class compatibility.
|
|
61
|
+
// Required only for skills whose last_threat_review is on or after the cutoff.
|
|
98
62
|
const COUNTERMEASURE_SECTION = 'Defensive Countermeasure Mapping';
|
|
99
63
|
const COUNTERMEASURE_CUTOFF = '2026-05-11';
|
|
100
64
|
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// sections surface as warnings; promoted to failures under --strict.
|
|
65
|
+
// Words of body text between a section heading and the next (or EOF) for the
|
|
66
|
+
// section to count as populated. Below it is a warning, an error under --strict.
|
|
104
67
|
const MIN_SECTION_BODY_WORDS = 20;
|
|
105
68
|
|
|
106
69
|
const PLACEHOLDER_PATTERNS = [
|
|
@@ -119,12 +82,9 @@ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
|
119
82
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
120
83
|
|
|
121
84
|
/*
|
|
122
|
-
*
|
|
123
|
-
* (
|
|
124
|
-
*
|
|
125
|
-
* malformed review date would pass the staleness gate against a date that
|
|
126
|
-
* never existed. Round-trip through Date.UTC and require every component to
|
|
127
|
-
* survive unchanged, which rejects the rollovers while accepting real dates.
|
|
85
|
+
* ISO_DATE_RE proves only the shape, and Date.parse silently rolls a
|
|
86
|
+
* non-calendar date over (2026-02-30 → 2026-03-02), measuring the staleness
|
|
87
|
+
* gate against a day that never existed.
|
|
128
88
|
*/
|
|
129
89
|
function isStrictIsoCalendarDate(s) {
|
|
130
90
|
if (typeof s !== 'string' || !ISO_DATE_RE.test(s)) return false;
|
|
@@ -151,9 +111,7 @@ function parseArgs(argv) {
|
|
|
151
111
|
} else if (a === '--quiet' || a === '-q') {
|
|
152
112
|
opts.quiet = true;
|
|
153
113
|
} else if (a === '--strict') {
|
|
154
|
-
//
|
|
155
|
-
// playbook air-gap gaps) to release-blocking failures. Used by the
|
|
156
|
-
// predeploy gate so a warned regression cannot scroll past.
|
|
114
|
+
// The predeploy gate runs --strict, so a warned regression cannot scroll past.
|
|
157
115
|
opts.strict = true;
|
|
158
116
|
} else if (a === '--help' || a === '-h') {
|
|
159
117
|
printHelp();
|
|
@@ -183,30 +141,16 @@ function readJson(p) {
|
|
|
183
141
|
}
|
|
184
142
|
|
|
185
143
|
/*
|
|
186
|
-
* Minimal YAML frontmatter parser
|
|
187
|
-
*
|
|
188
|
-
* key: "quoted string"
|
|
189
|
-
* key: bare-string
|
|
190
|
-
* key: [] (empty list)
|
|
191
|
-
* key:
|
|
192
|
-
* - item one
|
|
193
|
-
* - "item two"
|
|
194
|
-
* Anything outside this shape produces a parse error so we don't silently
|
|
195
|
-
* accept malformed frontmatter.
|
|
144
|
+
* Minimal YAML frontmatter parser: quoted or bare scalars, `[]`, and indented
|
|
145
|
+
* `- ` item lists. Anything outside that shape throws.
|
|
196
146
|
*/
|
|
197
147
|
function parseFrontmatter(text) {
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// consumed the `\n`, not the `\r`). `.` does not match `\r`, so that line's
|
|
201
|
-
// value would fail the per-line regex with a misleading "Could not parse
|
|
202
|
-
// frontmatter line N" on an otherwise valid CRLF skill.md.
|
|
148
|
+
// A dangling `\r` survives on the final frontmatter line (the close marker took
|
|
149
|
+
// the `\n`), and `.` does not match `\r`, so the per-line regex would fail.
|
|
203
150
|
const lines = text.split(/\r?\n/).map((l) => l.replace(/\r$/, ''));
|
|
204
151
|
const result = {};
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
// ("name: real-skill\nname: evil-skill") and silently take the second
|
|
208
|
-
// value — a skill-identity spoofing primitive. Refuse duplicates
|
|
209
|
-
// outright; an honest skill never has them.
|
|
152
|
+
// YAML's last-wins semantics would let "name: real\nname: evil" take the
|
|
153
|
+
// second value — a skill-identity spoof — so a duplicate key is refused.
|
|
210
154
|
const seenKeys = new Set();
|
|
211
155
|
let i = 0;
|
|
212
156
|
while (i < lines.length) {
|
|
@@ -261,11 +205,8 @@ function unquote(s) {
|
|
|
261
205
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
262
206
|
return s.slice(1, -1);
|
|
263
207
|
}
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
// The value is the quoted content; everything after the closing quote is a
|
|
267
|
-
// YAML comment. Only applied when the trailing segment is whitespace + `#`
|
|
268
|
-
// so a mid-string `#` inside an unquoted value is never mistaken for one.
|
|
208
|
+
// A quoted scalar trailed by an inline comment (`"standalone" # why`). Only
|
|
209
|
+
// when the tail is whitespace + `#`, so a `#` inside a value is not a comment.
|
|
269
210
|
if (first === '"' || first === "'") {
|
|
270
211
|
const close = s.indexOf(first, 1);
|
|
271
212
|
if (close > 0) {
|
|
@@ -294,18 +235,12 @@ function extractFrontmatterBlock(content) {
|
|
|
294
235
|
return { frontmatter: raw.replace(/^\r?\n/, ''), body: bodyStart, frontmatterRaw: raw };
|
|
295
236
|
}
|
|
296
237
|
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// The schema-driven pass below skips these so a field is never reported twice.
|
|
238
|
+
// Fields with a dedicated regex and error wording above; the schema-driven pass
|
|
239
|
+
// skips them so a field is never reported twice.
|
|
300
240
|
const SCHEMA_PATTERN_HANDLED_ELSEWHERE = new Set(['atlas_refs', 'attack_refs', 'data_deps']);
|
|
301
241
|
|
|
302
242
|
/* Enforce the enum and array-item pattern constraints declared in
|
|
303
|
-
* lib/schemas/skill-frontmatter.schema.json
|
|
304
|
-
* source of truth rather than a decorative artifact. Covers the schema's
|
|
305
|
-
* `enum` constraints (e.g. discovery_mode) and the `items.pattern` format
|
|
306
|
-
* checks on ref arrays (cwe_refs / d3fend_refs / dlp_refs / rfc_refs) that the
|
|
307
|
-
* hand-coded checks did not apply. Returns errors in the existing
|
|
308
|
-
* `frontmatter.<field> ...` voice. */
|
|
243
|
+
* lib/schemas/skill-frontmatter.schema.json. */
|
|
309
244
|
function schemaConstraintErrors(fm, schema) {
|
|
310
245
|
const errors = [];
|
|
311
246
|
const props = (schema && schema.properties) || {};
|
|
@@ -313,11 +248,8 @@ function schemaConstraintErrors(fm, schema) {
|
|
|
313
248
|
if (!(field in fm)) continue;
|
|
314
249
|
const value = fm[field];
|
|
315
250
|
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
// (e.g. `discovery_mode: [standalone]` parsed as an array, or a number),
|
|
319
|
-
// letting a malformed field bypass validation entirely. Flag the type
|
|
320
|
-
// mismatch first; only run the membership check on an actual string.
|
|
251
|
+
// Type first, membership only on a string: guarding on `typeof value ===
|
|
252
|
+
// 'string'` would let `discovery_mode: [standalone]` through unvalidated.
|
|
321
253
|
if (Array.isArray(spec.enum)) {
|
|
322
254
|
if (typeof value !== 'string') {
|
|
323
255
|
errors.push(
|
|
@@ -350,13 +282,10 @@ function schemaConstraintErrors(fm, schema) {
|
|
|
350
282
|
return errors;
|
|
351
283
|
}
|
|
352
284
|
|
|
353
|
-
/*
|
|
285
|
+
/* Returns { errors, warnings } — the review-staleness soft cap warns without
|
|
286
|
+
* failing, so callers must read both arrays. */
|
|
354
287
|
function validateFrontmatter(fm, skillName) {
|
|
355
288
|
const errors = [];
|
|
356
|
-
// v0.13.0: validateFrontmatter now ALSO surfaces warnings (e.g. the
|
|
357
|
-
// last_threat_review 180-day soft cap). Return signature changes from
|
|
358
|
-
// `string[]` to `{ errors: string[], warnings: string[] }` — callers
|
|
359
|
-
// updated accordingly.
|
|
360
289
|
const warnings = [];
|
|
361
290
|
|
|
362
291
|
for (const key of Object.keys(fm)) {
|
|
@@ -467,10 +396,6 @@ function validateFrontmatter(fm, skillName) {
|
|
|
467
396
|
`frontmatter.last_threat_review "${fm.last_threat_review}" is not a valid ISO date (YYYY-MM-DD). A structurally ISO but non-calendar value (e.g. 2026-13-99 or a rollover like 2026-02-30) is rejected so a malformed date cannot slip past the staleness gate.`,
|
|
468
397
|
);
|
|
469
398
|
} else {
|
|
470
|
-
// v0.13.0: Hard Rule #8 forcing function — refuse skills whose
|
|
471
|
-
// last_threat_review is older than the staleness threshold.
|
|
472
|
-
// 180-day soft cap (warn), 365-day hard cap (fail). Operators on
|
|
473
|
-
// older releases who don't refresh fall off the supported window.
|
|
474
399
|
const days = Math.floor((Date.now() - Date.parse(fm.last_threat_review + 'T00:00:00Z')) / (24 * 60 * 60 * 1000));
|
|
475
400
|
if (days > 365) {
|
|
476
401
|
errors.push(
|
|
@@ -484,30 +409,19 @@ function validateFrontmatter(fm, skillName) {
|
|
|
484
409
|
}
|
|
485
410
|
}
|
|
486
411
|
|
|
487
|
-
// Drive enum + ref-pattern enforcement from the published schema so the
|
|
488
|
-
// shipped artifact and this gate cannot diverge.
|
|
489
412
|
errors.push(...schemaConstraintErrors(fm, FRONTMATTER_SCHEMA));
|
|
490
413
|
|
|
491
414
|
return { errors, warnings };
|
|
492
415
|
}
|
|
493
416
|
|
|
494
|
-
/*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* - missing[] — sections with no `^## <Section Name>` heading anywhere
|
|
498
|
-
* in the body (case-insensitive). Hard failure.
|
|
499
|
-
* - headerOnly[] — sections whose heading exists but whose body between
|
|
500
|
-
* that heading and the next heading is shorter than
|
|
501
|
-
* MIN_SECTION_BODY_WORDS words. A warning by default;
|
|
502
|
-
* promoted to an error under --strict. */
|
|
417
|
+
/* Returns { missing, headerOnly }: sections with no heading in the body, and
|
|
418
|
+
* sections whose heading exists but whose text runs shorter than
|
|
419
|
+
* MIN_SECTION_BODY_WORDS. */
|
|
503
420
|
function findMissingSections(body, requiredSections) {
|
|
504
421
|
const sections = requiredSections || REQUIRED_SECTIONS;
|
|
505
422
|
const lines = body.split(/\r?\n/);
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
// documentation, not a real section, and must not satisfy a required-section
|
|
509
|
-
// check (otherwise a skill whose required sections appear only inside a fence
|
|
510
|
-
// passes the gate vacuously).
|
|
423
|
+
// Headings inside a fenced block are documentation: counting them lets a skill
|
|
424
|
+
// whose sections appear only inside a fence pass the gate vacuously.
|
|
511
425
|
const headings = [];
|
|
512
426
|
let inFence = false;
|
|
513
427
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -518,17 +432,13 @@ function findMissingSections(body, requiredSections) {
|
|
|
518
432
|
headings.push({ line: i, depth: m[1].length, title: m[2].trim() });
|
|
519
433
|
}
|
|
520
434
|
}
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
// "## TTP Mapping (MITRE ATT&CK Enterprise, mid-2026)"). The required
|
|
524
|
-
// section name must appear as a leading token followed by end-of-string
|
|
525
|
-
// or a non-alphanumeric character (paren, dash, colon).
|
|
435
|
+
// Case-insensitive, and tolerant of a trailing qualifier ("## Threat Context
|
|
436
|
+
// (mid-2026)"): the name must lead, then end-of-string or a non-alphanumeric.
|
|
526
437
|
const findHeading = (title) => {
|
|
527
438
|
const t = title.toLowerCase();
|
|
528
439
|
return headings.find((h) => {
|
|
529
|
-
// Required sections are
|
|
530
|
-
// "
|
|
531
|
-
// satisfy the standalone "## Compliance Theater Check" requirement.
|
|
440
|
+
// Required sections are H1/H2: a nested H3 "### Compliance Theater Check
|
|
441
|
+
// Result" must not satisfy the standalone "## Compliance Theater Check".
|
|
532
442
|
if (h.depth > 2) return false;
|
|
533
443
|
const lower = h.title.toLowerCase();
|
|
534
444
|
if (lower === t) return true;
|
|
@@ -549,7 +459,6 @@ function findMissingSections(body, requiredSections) {
|
|
|
549
459
|
missing.push(section);
|
|
550
460
|
continue;
|
|
551
461
|
}
|
|
552
|
-
// Find the next heading at the same or shallower depth, or EOF.
|
|
553
462
|
const idx = headings.indexOf(h);
|
|
554
463
|
let endLine = lines.length;
|
|
555
464
|
for (let j = idx + 1; j < headings.length; j++) {
|
|
@@ -676,11 +585,8 @@ function lintSkill(entry, ctx) {
|
|
|
676
585
|
}
|
|
677
586
|
}
|
|
678
587
|
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
// failures under --strict (the predeploy gate). If
|
|
682
|
-
// data/attack-techniques.json is missing entirely the ctx.attackKeys set
|
|
683
|
-
// is null — skip the check (the gate degrades gracefully).
|
|
588
|
+
// Unresolved attack_refs warn, and fail under --strict. ctx.attackKeys is
|
|
589
|
+
// null when data/attack-techniques.json is absent, and the check is skipped.
|
|
684
590
|
if (Array.isArray(fm.attack_refs) && ctx.attackKeys) {
|
|
685
591
|
for (const ref of fm.attack_refs) {
|
|
686
592
|
if (!ctx.attackKeys.has(ref)) {
|
|
@@ -691,22 +597,9 @@ function lintSkill(entry, ctx) {
|
|
|
691
597
|
}
|
|
692
598
|
}
|
|
693
599
|
|
|
694
|
-
// Hard Rule #1
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
// enforced for catalog ENTRIES by lib/validate-cve-catalog.js — and
|
|
698
|
-
// this body-scan extends it to the skill prose layer.
|
|
699
|
-
//
|
|
700
|
-
// v0.13.2 introduced this as a warning while the 2 pre-existing
|
|
701
|
-
// violations (ransomware-response cited CVE-2024-21762,
|
|
702
|
-
// cloud-iam-incident cited CVE-2026-21370) were triaged. v0.13.3
|
|
703
|
-
// flips to hard error now that both have been resolved (the
|
|
704
|
-
// Fortinet CVE landed in the catalog; the placeholder CVE was
|
|
705
|
-
// removed from the cloud-iam-incident body).
|
|
706
|
-
//
|
|
707
|
-
// Draft references stay as warnings — operators promote drafts
|
|
708
|
-
// on their own cadence and the catalog frequently carries
|
|
709
|
-
// auto-imported drafts that skills can legitimately cite.
|
|
600
|
+
// Hard Rule #1 at the prose layer: every CVE-* / MAL-* cited in a skill body
|
|
601
|
+
// must resolve in data/cve-catalog.json. A `_draft: true` entry warns rather
|
|
602
|
+
// than failing — operators promote drafts on their own cadence.
|
|
710
603
|
if (ctx.cveCatalog && body && typeof body === 'string') {
|
|
711
604
|
const cveRefRe = /\b(CVE-\d{4}-\d{4,7}|MAL-\d{4}-[A-Z0-9-]+)\b/g;
|
|
712
605
|
const seen = new Set();
|
|
@@ -728,18 +621,11 @@ function lintSkill(entry, ctx) {
|
|
|
728
621
|
}
|
|
729
622
|
}
|
|
730
623
|
|
|
731
|
-
// L3 — Defensive Countermeasure Mapping is required for skills reviewed
|
|
732
|
-
// on or after COUNTERMEASURE_CUTOFF. Pre-cutoff skills are exempt. The
|
|
733
|
-
// section's absence on a post-cutoff skill is a warning by default so
|
|
734
|
-
// existing skills can add the section gradually; promoted to a hard
|
|
735
|
-
// failure under --strict.
|
|
736
624
|
const { missing, headerOnly } = findMissingSections(body, REQUIRED_SECTIONS);
|
|
737
625
|
for (const s of missing) {
|
|
738
626
|
skillErrors.push(`body: missing required section "${s}"`);
|
|
739
627
|
}
|
|
740
628
|
for (const ho of headerOnly) {
|
|
741
|
-
// L1 — Header-only sections are warnings by default; promoted to a
|
|
742
|
-
// failure under --strict.
|
|
743
629
|
skillWarnings.push(
|
|
744
630
|
`body: section "${ho.section}" has only ${ho.wordCount} words of body text (need >= ${MIN_SECTION_BODY_WORDS}); an error under --strict`,
|
|
745
631
|
);
|
|
@@ -776,7 +662,6 @@ function loadContext() {
|
|
|
776
662
|
const frameworks = readJson(FRAMEWORK_GAPS_PATH);
|
|
777
663
|
const atlasKeys = new Set(Object.keys(atlas).filter((k) => !k.startsWith('_')));
|
|
778
664
|
const frameworkKeys = new Set(Object.keys(frameworks).filter((k) => !k.startsWith('_')));
|
|
779
|
-
// Optional catalogs — load if present, otherwise treat as empty.
|
|
780
665
|
function loadKeys(p) {
|
|
781
666
|
const s = new Set();
|
|
782
667
|
if (fs.existsSync(p)) {
|
|
@@ -785,20 +670,15 @@ function loadContext() {
|
|
|
785
670
|
}
|
|
786
671
|
return s;
|
|
787
672
|
}
|
|
788
|
-
//
|
|
789
|
-
// ctx.attackKeys is null and the L2 check is skipped.
|
|
673
|
+
// Null when data/attack-techniques.json is absent; the attack_refs check skips.
|
|
790
674
|
let attackKeys = null;
|
|
791
675
|
if (fs.existsSync(ATTACK_REFS_PATH)) {
|
|
792
676
|
attackKeys = new Set();
|
|
793
677
|
const j = readJson(ATTACK_REFS_PATH);
|
|
794
678
|
for (const k of Object.keys(j)) if (!k.startsWith('_')) attackKeys.add(k);
|
|
795
679
|
}
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
// against the source-of-truth catalog. Loaded as a full object (not
|
|
799
|
-
// just keys) so the body-scan can also surface `_draft: true` matches
|
|
800
|
-
// as warnings rather than errors — operators promote drafts on their
|
|
801
|
-
// own cadence.
|
|
680
|
+
// Loaded whole rather than as a key set, so the body scan can tell a missing
|
|
681
|
+
// CVE from a `_draft: true` one.
|
|
802
682
|
const cveCatalog = fs.existsSync(CVE_CATALOG_PATH) ? readJson(CVE_CATALOG_PATH) : {};
|
|
803
683
|
|
|
804
684
|
return {
|
|
@@ -814,25 +694,13 @@ function loadContext() {
|
|
|
814
694
|
}
|
|
815
695
|
|
|
816
696
|
/*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
* Walk every subdirectory of skills/ and assert each skill.md file is
|
|
820
|
-
* referenced by exactly one manifest entry. Catches the v0.12.8
|
|
821
|
-
* stash-restore class: a directory left behind on disk that nobody
|
|
822
|
-
* signs because nobody listed it in the manifest, then the next
|
|
823
|
-
* `npm pack` ships an unsigned skill (or worse, conflicts with a
|
|
824
|
-
* future manifest entry of the same name).
|
|
825
|
-
*
|
|
826
|
-
* @param {Array<{path: string}>} manifestSkills
|
|
827
|
-
* @returns {string[]} list of orphan filesystem paths (relative)
|
|
697
|
+
* Every skill.md under skills/ must be referenced by a manifest entry: a
|
|
698
|
+
* directory nobody listed is signed by nobody. Returns repo-relative paths.
|
|
828
699
|
*/
|
|
829
700
|
function findOrphanSkillFiles(manifestSkills) {
|
|
830
701
|
if (!fs.existsSync(SKILLS_DIR)) return [];
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
// path.sep split was a no-op on Linux and incorrect on Windows when
|
|
834
|
-
// mixed separators arrived through other ingest paths; the cleaner
|
|
835
|
-
// contract is to normalise the comparison key directly.
|
|
702
|
+
// Manifest paths are forward-slash by contract (lib/verify.js validateSkillPath
|
|
703
|
+
// rejects backslashes), so normalise rather than splitting on path.sep.
|
|
836
704
|
const referenced = new Set(
|
|
837
705
|
manifestSkills.map((s) => String(s.path).replace(/\\/g, '/')),
|
|
838
706
|
);
|
|
@@ -848,17 +716,9 @@ function findOrphanSkillFiles(manifestSkills) {
|
|
|
848
716
|
return orphans;
|
|
849
717
|
}
|
|
850
718
|
|
|
851
|
-
// Manifest cover arrays that must resolve to a real catalog entry, paired
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
// any skill body), and those manifest-only refs are what build-indexes'
|
|
855
|
-
// reverse-ref surface and refresh-reverse-refs read — yet the per-skill
|
|
856
|
-
// frontmatter ref-resolution above never sees them. Without this pass a
|
|
857
|
-
// typo'd or stale manifest-only ref (e.g. a hand-edit, or one re-signed
|
|
858
|
-
// into manifest_signature) becomes an orphaned control reference in the
|
|
859
|
-
// signed manifest + every derived surface, the exact "no orphaned controls"
|
|
860
|
-
// failure (AGENTS.md Hard Rule #4) the frontmatter resolution prevents —
|
|
861
|
-
// applied to the manifest-only delta the frontmatter pass is blind to.
|
|
719
|
+
// Manifest cover arrays that must resolve to a real catalog entry, paired with
|
|
720
|
+
// the loadContext() key set. These manifest-only refs feed build-indexes'
|
|
721
|
+
// reverse-ref surface and refresh-reverse-refs; the frontmatter pass never sees them.
|
|
862
722
|
const MANIFEST_COVER_RESOLUTION = [
|
|
863
723
|
{ field: 'atlas_refs', ctxKey: 'atlasKeys', catalog: 'data/atlas-ttps.json' },
|
|
864
724
|
{ field: 'attack_refs', ctxKey: 'attackKeys', catalog: 'data/attack-techniques.json' },
|
|
@@ -870,18 +730,9 @@ const MANIFEST_COVER_RESOLUTION = [
|
|
|
870
730
|
];
|
|
871
731
|
|
|
872
732
|
/*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
* per-skill frontmatter ref-resolution (which reads only skill bodies) to
|
|
877
|
-
* the MANIFEST cover arrays — the signed, index-feeding source of truth
|
|
878
|
-
* that build-indexes' reverse-ref surface and refresh-reverse-refs read.
|
|
879
|
-
*
|
|
880
|
-
* A ctxKey set that is null (the optional attack-techniques.json catalog is
|
|
881
|
-
* absent in older trees) skips that field, mirroring loadContext()'s
|
|
882
|
-
* graceful-degradation contract for ctx.attackKeys.
|
|
883
|
-
*
|
|
884
|
-
* @returns {string[]} `<skill>.<field>: <ref> not present in <catalog>` lines
|
|
733
|
+
* Assert every ref in each manifest entry's cover arrays resolves in the
|
|
734
|
+
* matching catalog. A null ctx key set — an absent optional catalog — skips
|
|
735
|
+
* that field.
|
|
885
736
|
*/
|
|
886
737
|
function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
|
|
887
738
|
const errors = [];
|
|
@@ -901,11 +752,8 @@ function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
|
|
|
901
752
|
return errors;
|
|
902
753
|
}
|
|
903
754
|
|
|
904
|
-
// Substrings that
|
|
905
|
-
//
|
|
906
|
-
// Conservative-by-design — false positives are surfaced as `warn` (not
|
|
907
|
-
// `error`) and a playbook author who has reviewed the source can suppress
|
|
908
|
-
// by adding an air_gap_alternative even when the source itself is offline.
|
|
755
|
+
// Substrings that mark an artifact `source` as a network call. Deliberately
|
|
756
|
+
// broad: a hit only warns, and an air_gap_alternative silences it.
|
|
909
757
|
const PLAYBOOK_NET_PATTERNS = [
|
|
910
758
|
'https://', 'http://', 'gh api', 'gh release', 'curl ', 'wget ', 'fetch ',
|
|
911
759
|
];
|
|
@@ -913,16 +761,10 @@ const PLAYBOOK_NET_PATTERNS = [
|
|
|
913
761
|
const PLAYBOOK_DIR = path.join(DATA_DIR, 'playbooks');
|
|
914
762
|
|
|
915
763
|
/**
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
* conditional (added v0.12.24) catches this for playbooks marked
|
|
921
|
-
* `_meta.air_gap_mode: true`; this lint surfaces the gap for every
|
|
922
|
-
* playbook, on the principle that a non-air-gap playbook may still be
|
|
923
|
-
* invoked under `exceptd --air-gap` and operators deserve the warning.
|
|
924
|
-
*
|
|
925
|
-
* Returns an array of `{ playbook, artifact_id, source }` warning records.
|
|
764
|
+
* Warn on any data/playbooks/*.json look artifact whose `source` makes a network
|
|
765
|
+
* call without a sibling `air_gap_alternative`. The schema enforces that only for
|
|
766
|
+
* `_meta.air_gap_mode: true`, but any playbook can run under `--air-gap`. Returns
|
|
767
|
+
* `{ playbook, artifact_id, source }` records.
|
|
926
768
|
*/
|
|
927
769
|
function lintPlaybookAirGap() {
|
|
928
770
|
const warnings = [];
|
|
@@ -995,9 +837,7 @@ function main() {
|
|
|
995
837
|
}
|
|
996
838
|
}
|
|
997
839
|
|
|
998
|
-
//
|
|
999
|
-
// A targeted single-skill lint is for diagnosing one entry; running
|
|
1000
|
-
// the orphan walk there would surface unrelated findings.
|
|
840
|
+
// These passes run only on a full lint; a --skill run would show unrelated noise.
|
|
1001
841
|
let orphans = [];
|
|
1002
842
|
let manifestRefErrors = [];
|
|
1003
843
|
let airGapWarnings = [];
|
|
@@ -1008,19 +848,14 @@ function main() {
|
|
|
1008
848
|
console.log(` - skill.md exists on disk but not in manifest: ${o}`);
|
|
1009
849
|
console.log(` fix: re-run sign-all (\`node $(exceptd path)/lib/sign.js sign-all\` from a contributor checkout) after adding it to manifest.json, OR delete the orphan directory`);
|
|
1010
850
|
}
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
// resolves the manifest cover arrays the reverse-ref surface reads, so a
|
|
1014
|
-
// typo'd/stale manifest-only ref can't ship as an orphaned control. A
|
|
1015
|
-
// hard failure (not a --strict warning): an unresolved control ref is
|
|
1016
|
-
// unconditionally wrong, matching the frontmatter ref-resolution voice.
|
|
851
|
+
// Hard Rule #4, no orphaned controls: an unresolved control ref is
|
|
852
|
+
// unconditionally wrong, so this fails rather than warning under --strict.
|
|
1017
853
|
manifestRefErrors = findUnresolvedManifestCoverRefs(manifest.skills, ctx);
|
|
1018
854
|
for (const e of manifestRefErrors) {
|
|
1019
855
|
console.log(`FAIL <manifest-cover-ref>`);
|
|
1020
856
|
console.log(` - ${e}`);
|
|
1021
857
|
console.log(` fix: correct the typo'd/stale ref in manifest.json (or add the entry to the catalog), then re-run sign-all + refresh-reverse-refs + build-indexes`);
|
|
1022
858
|
}
|
|
1023
|
-
// P4 — air-gap completeness lint over data/playbooks/*.json.
|
|
1024
859
|
airGapWarnings = lintPlaybookAirGap();
|
|
1025
860
|
for (const w of airGapWarnings) {
|
|
1026
861
|
console.log(`WARN playbook:${w.playbook}`);
|
|
@@ -1043,8 +878,6 @@ function main() {
|
|
|
1043
878
|
console.log(
|
|
1044
879
|
`\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${manifestRefSummary}${airGapSummary}.`,
|
|
1045
880
|
);
|
|
1046
|
-
// --strict treats any warning (per-skill or playbook air-gap) as a
|
|
1047
|
-
// release-blocking failure so a warned regression cannot ship silently.
|
|
1048
881
|
const strictFail = opts.strict && (warned > 0 || (airGapWarnings && airGapWarnings.length > 0));
|
|
1049
882
|
if (strictFail) {
|
|
1050
883
|
console.log(`[lint-skills] --strict: ${warned + (airGapWarnings ? airGapWarnings.length : 0)} warning(s) treated as failures.`);
|
|
@@ -1053,8 +886,7 @@ function main() {
|
|
|
1053
886
|
return;
|
|
1054
887
|
}
|
|
1055
888
|
|
|
1056
|
-
//
|
|
1057
|
-
// (e.g., orchestrator `watchlist` command) so they don't reinvent it.
|
|
889
|
+
// The frontmatter parser is exported so `watchlist` does not grow a second one.
|
|
1058
890
|
module.exports = {
|
|
1059
891
|
parseFrontmatter,
|
|
1060
892
|
extractFrontmatterBlock,
|