@blamejs/exceptd-skills 0.19.33 → 0.19.35
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 +28 -0
- package/bin/exceptd.js +895 -2828
- package/data/_indexes/_meta.json +2 -2
- 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 +170 -76
- package/lib/collectors/cicd-pipeline-compromise.js +113 -136
- 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 +198 -211
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +130 -118
- package/lib/collectors/scan-excludes.js +33 -139
- 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 -155
- 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 +39 -113
- 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 +88 -236
- package/lib/playbook-runner.js +759 -2107
- package/lib/prefetch.js +101 -376
- package/lib/refresh-external.js +199 -633
- package/lib/refresh-network.js +78 -311
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +85 -146
- 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 +28 -27
- package/lib/upstream-check-cli.js +36 -29
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +52 -121
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +78 -286
- 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 -413
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +242 -242
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +29 -28
- 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 +21 -31
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +26 -57
- 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 +63 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +62 -81
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +83 -198
- 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 +7 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +7 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +7 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +63 -148
- package/scripts/release.js +69 -234
- package/scripts/run-e2e-scenarios.js +26 -73
- 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 -141
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;
|
|
@@ -140,8 +100,20 @@ function isStrictIsoCalendarDate(s) {
|
|
|
140
100
|
const KEBAB_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
141
101
|
const JSON_FILENAME_RE = /^[A-Za-z0-9._-]+\.json$/;
|
|
142
102
|
|
|
103
|
+
// `helpExitCode` is null unless the run is over before linting starts: 0 after
|
|
104
|
+
// --help, 2 after an unknown argument. parseArgs never terminates the process —
|
|
105
|
+
// it returns the code and the caller must honour it and stop.
|
|
106
|
+
//
|
|
107
|
+
// The invariant this keeps: every exit in this file goes through safeExit(), so
|
|
108
|
+
// no path here can write to stdout and then terminate synchronously. That is the
|
|
109
|
+
// `process-exit-after-stdout-write` shape, and it is a convention held file-wide
|
|
110
|
+
// rather than a repair of an observed truncation: Node writes pipe stdout
|
|
111
|
+
// synchronously on Windows and Linux (asynchronously on macOS), and the help
|
|
112
|
+
// block is a few hundred bytes, so process.exit() did not in fact truncate it
|
|
113
|
+
// here. Holding the invariant means a future help block that grows, or a run on
|
|
114
|
+
// a platform with async pipe writes, cannot reintroduce the class.
|
|
143
115
|
function parseArgs(argv) {
|
|
144
|
-
const opts = { skill: null, quiet: false, strict: false };
|
|
116
|
+
const opts = { skill: null, quiet: false, strict: false, helpExitCode: null };
|
|
145
117
|
for (let i = 2; i < argv.length; i++) {
|
|
146
118
|
const a = argv[i];
|
|
147
119
|
if (a === '--skill') {
|
|
@@ -151,17 +123,17 @@ function parseArgs(argv) {
|
|
|
151
123
|
} else if (a === '--quiet' || a === '-q') {
|
|
152
124
|
opts.quiet = true;
|
|
153
125
|
} 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.
|
|
126
|
+
// The predeploy gate runs --strict, so a warned regression cannot scroll past.
|
|
157
127
|
opts.strict = true;
|
|
158
128
|
} else if (a === '--help' || a === '-h') {
|
|
159
129
|
printHelp();
|
|
160
|
-
|
|
130
|
+
opts.helpExitCode = 0;
|
|
131
|
+
return opts;
|
|
161
132
|
} else {
|
|
162
133
|
console.error(`Unknown argument: ${a}`);
|
|
163
134
|
printHelp();
|
|
164
|
-
|
|
135
|
+
opts.helpExitCode = 2;
|
|
136
|
+
return opts;
|
|
165
137
|
}
|
|
166
138
|
}
|
|
167
139
|
return opts;
|
|
@@ -183,30 +155,16 @@ function readJson(p) {
|
|
|
183
155
|
}
|
|
184
156
|
|
|
185
157
|
/*
|
|
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.
|
|
158
|
+
* Minimal YAML frontmatter parser: quoted or bare scalars, `[]`, and indented
|
|
159
|
+
* `- ` item lists. Anything outside that shape throws.
|
|
196
160
|
*/
|
|
197
161
|
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.
|
|
162
|
+
// A dangling `\r` survives on the final frontmatter line (the close marker took
|
|
163
|
+
// the `\n`), and `.` does not match `\r`, so the per-line regex would fail.
|
|
203
164
|
const lines = text.split(/\r?\n/).map((l) => l.replace(/\r$/, ''));
|
|
204
165
|
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.
|
|
166
|
+
// YAML's last-wins semantics would let "name: real\nname: evil" take the
|
|
167
|
+
// second value — a skill-identity spoof — so a duplicate key is refused.
|
|
210
168
|
const seenKeys = new Set();
|
|
211
169
|
let i = 0;
|
|
212
170
|
while (i < lines.length) {
|
|
@@ -261,11 +219,8 @@ function unquote(s) {
|
|
|
261
219
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
262
220
|
return s.slice(1, -1);
|
|
263
221
|
}
|
|
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.
|
|
222
|
+
// A quoted scalar trailed by an inline comment (`"standalone" # why`). Only
|
|
223
|
+
// when the tail is whitespace + `#`, so a `#` inside a value is not a comment.
|
|
269
224
|
if (first === '"' || first === "'") {
|
|
270
225
|
const close = s.indexOf(first, 1);
|
|
271
226
|
if (close > 0) {
|
|
@@ -294,18 +249,12 @@ function extractFrontmatterBlock(content) {
|
|
|
294
249
|
return { frontmatter: raw.replace(/^\r?\n/, ''), body: bodyStart, frontmatterRaw: raw };
|
|
295
250
|
}
|
|
296
251
|
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// The schema-driven pass below skips these so a field is never reported twice.
|
|
252
|
+
// Fields with a dedicated regex and error wording above; the schema-driven pass
|
|
253
|
+
// skips them so a field is never reported twice.
|
|
300
254
|
const SCHEMA_PATTERN_HANDLED_ELSEWHERE = new Set(['atlas_refs', 'attack_refs', 'data_deps']);
|
|
301
255
|
|
|
302
256
|
/* 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. */
|
|
257
|
+
* lib/schemas/skill-frontmatter.schema.json. */
|
|
309
258
|
function schemaConstraintErrors(fm, schema) {
|
|
310
259
|
const errors = [];
|
|
311
260
|
const props = (schema && schema.properties) || {};
|
|
@@ -313,11 +262,8 @@ function schemaConstraintErrors(fm, schema) {
|
|
|
313
262
|
if (!(field in fm)) continue;
|
|
314
263
|
const value = fm[field];
|
|
315
264
|
|
|
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.
|
|
265
|
+
// Type first, membership only on a string: guarding on `typeof value ===
|
|
266
|
+
// 'string'` would let `discovery_mode: [standalone]` through unvalidated.
|
|
321
267
|
if (Array.isArray(spec.enum)) {
|
|
322
268
|
if (typeof value !== 'string') {
|
|
323
269
|
errors.push(
|
|
@@ -350,13 +296,10 @@ function schemaConstraintErrors(fm, schema) {
|
|
|
350
296
|
return errors;
|
|
351
297
|
}
|
|
352
298
|
|
|
353
|
-
/*
|
|
299
|
+
/* Returns { errors, warnings } — the review-staleness soft cap warns without
|
|
300
|
+
* failing, so callers must read both arrays. */
|
|
354
301
|
function validateFrontmatter(fm, skillName) {
|
|
355
302
|
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
303
|
const warnings = [];
|
|
361
304
|
|
|
362
305
|
for (const key of Object.keys(fm)) {
|
|
@@ -467,10 +410,6 @@ function validateFrontmatter(fm, skillName) {
|
|
|
467
410
|
`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
411
|
);
|
|
469
412
|
} 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
413
|
const days = Math.floor((Date.now() - Date.parse(fm.last_threat_review + 'T00:00:00Z')) / (24 * 60 * 60 * 1000));
|
|
475
414
|
if (days > 365) {
|
|
476
415
|
errors.push(
|
|
@@ -484,30 +423,19 @@ function validateFrontmatter(fm, skillName) {
|
|
|
484
423
|
}
|
|
485
424
|
}
|
|
486
425
|
|
|
487
|
-
// Drive enum + ref-pattern enforcement from the published schema so the
|
|
488
|
-
// shipped artifact and this gate cannot diverge.
|
|
489
426
|
errors.push(...schemaConstraintErrors(fm, FRONTMATTER_SCHEMA));
|
|
490
427
|
|
|
491
428
|
return { errors, warnings };
|
|
492
429
|
}
|
|
493
430
|
|
|
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. */
|
|
431
|
+
/* Returns { missing, headerOnly }: sections with no heading in the body, and
|
|
432
|
+
* sections whose heading exists but whose text runs shorter than
|
|
433
|
+
* MIN_SECTION_BODY_WORDS. */
|
|
503
434
|
function findMissingSections(body, requiredSections) {
|
|
504
435
|
const sections = requiredSections || REQUIRED_SECTIONS;
|
|
505
436
|
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).
|
|
437
|
+
// Headings inside a fenced block are documentation: counting them lets a skill
|
|
438
|
+
// whose sections appear only inside a fence pass the gate vacuously.
|
|
511
439
|
const headings = [];
|
|
512
440
|
let inFence = false;
|
|
513
441
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -518,17 +446,13 @@ function findMissingSections(body, requiredSections) {
|
|
|
518
446
|
headings.push({ line: i, depth: m[1].length, title: m[2].trim() });
|
|
519
447
|
}
|
|
520
448
|
}
|
|
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).
|
|
449
|
+
// Case-insensitive, and tolerant of a trailing qualifier ("## Threat Context
|
|
450
|
+
// (mid-2026)"): the name must lead, then end-of-string or a non-alphanumeric.
|
|
526
451
|
const findHeading = (title) => {
|
|
527
452
|
const t = title.toLowerCase();
|
|
528
453
|
return headings.find((h) => {
|
|
529
|
-
// Required sections are
|
|
530
|
-
// "
|
|
531
|
-
// satisfy the standalone "## Compliance Theater Check" requirement.
|
|
454
|
+
// Required sections are H1/H2: a nested H3 "### Compliance Theater Check
|
|
455
|
+
// Result" must not satisfy the standalone "## Compliance Theater Check".
|
|
532
456
|
if (h.depth > 2) return false;
|
|
533
457
|
const lower = h.title.toLowerCase();
|
|
534
458
|
if (lower === t) return true;
|
|
@@ -549,7 +473,6 @@ function findMissingSections(body, requiredSections) {
|
|
|
549
473
|
missing.push(section);
|
|
550
474
|
continue;
|
|
551
475
|
}
|
|
552
|
-
// Find the next heading at the same or shallower depth, or EOF.
|
|
553
476
|
const idx = headings.indexOf(h);
|
|
554
477
|
let endLine = lines.length;
|
|
555
478
|
for (let j = idx + 1; j < headings.length; j++) {
|
|
@@ -676,11 +599,8 @@ function lintSkill(entry, ctx) {
|
|
|
676
599
|
}
|
|
677
600
|
}
|
|
678
601
|
|
|
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).
|
|
602
|
+
// Unresolved attack_refs warn, and fail under --strict. ctx.attackKeys is
|
|
603
|
+
// null when data/attack-techniques.json is absent, and the check is skipped.
|
|
684
604
|
if (Array.isArray(fm.attack_refs) && ctx.attackKeys) {
|
|
685
605
|
for (const ref of fm.attack_refs) {
|
|
686
606
|
if (!ctx.attackKeys.has(ref)) {
|
|
@@ -691,22 +611,9 @@ function lintSkill(entry, ctx) {
|
|
|
691
611
|
}
|
|
692
612
|
}
|
|
693
613
|
|
|
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.
|
|
614
|
+
// Hard Rule #1 at the prose layer: every CVE-* / MAL-* cited in a skill body
|
|
615
|
+
// must resolve in data/cve-catalog.json. A `_draft: true` entry warns rather
|
|
616
|
+
// than failing — operators promote drafts on their own cadence.
|
|
710
617
|
if (ctx.cveCatalog && body && typeof body === 'string') {
|
|
711
618
|
const cveRefRe = /\b(CVE-\d{4}-\d{4,7}|MAL-\d{4}-[A-Z0-9-]+)\b/g;
|
|
712
619
|
const seen = new Set();
|
|
@@ -728,18 +635,11 @@ function lintSkill(entry, ctx) {
|
|
|
728
635
|
}
|
|
729
636
|
}
|
|
730
637
|
|
|
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
638
|
const { missing, headerOnly } = findMissingSections(body, REQUIRED_SECTIONS);
|
|
737
639
|
for (const s of missing) {
|
|
738
640
|
skillErrors.push(`body: missing required section "${s}"`);
|
|
739
641
|
}
|
|
740
642
|
for (const ho of headerOnly) {
|
|
741
|
-
// L1 — Header-only sections are warnings by default; promoted to a
|
|
742
|
-
// failure under --strict.
|
|
743
643
|
skillWarnings.push(
|
|
744
644
|
`body: section "${ho.section}" has only ${ho.wordCount} words of body text (need >= ${MIN_SECTION_BODY_WORDS}); an error under --strict`,
|
|
745
645
|
);
|
|
@@ -776,7 +676,6 @@ function loadContext() {
|
|
|
776
676
|
const frameworks = readJson(FRAMEWORK_GAPS_PATH);
|
|
777
677
|
const atlasKeys = new Set(Object.keys(atlas).filter((k) => !k.startsWith('_')));
|
|
778
678
|
const frameworkKeys = new Set(Object.keys(frameworks).filter((k) => !k.startsWith('_')));
|
|
779
|
-
// Optional catalogs — load if present, otherwise treat as empty.
|
|
780
679
|
function loadKeys(p) {
|
|
781
680
|
const s = new Set();
|
|
782
681
|
if (fs.existsSync(p)) {
|
|
@@ -785,20 +684,15 @@ function loadContext() {
|
|
|
785
684
|
}
|
|
786
685
|
return s;
|
|
787
686
|
}
|
|
788
|
-
//
|
|
789
|
-
// ctx.attackKeys is null and the L2 check is skipped.
|
|
687
|
+
// Null when data/attack-techniques.json is absent; the attack_refs check skips.
|
|
790
688
|
let attackKeys = null;
|
|
791
689
|
if (fs.existsSync(ATTACK_REFS_PATH)) {
|
|
792
690
|
attackKeys = new Set();
|
|
793
691
|
const j = readJson(ATTACK_REFS_PATH);
|
|
794
692
|
for (const k of Object.keys(j)) if (!k.startsWith('_')) attackKeys.add(k);
|
|
795
693
|
}
|
|
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.
|
|
694
|
+
// Loaded whole rather than as a key set, so the body scan can tell a missing
|
|
695
|
+
// CVE from a `_draft: true` one.
|
|
802
696
|
const cveCatalog = fs.existsSync(CVE_CATALOG_PATH) ? readJson(CVE_CATALOG_PATH) : {};
|
|
803
697
|
|
|
804
698
|
return {
|
|
@@ -814,25 +708,13 @@ function loadContext() {
|
|
|
814
708
|
}
|
|
815
709
|
|
|
816
710
|
/*
|
|
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)
|
|
711
|
+
* Every skill.md under skills/ must be referenced by a manifest entry: a
|
|
712
|
+
* directory nobody listed is signed by nobody. Returns repo-relative paths.
|
|
828
713
|
*/
|
|
829
714
|
function findOrphanSkillFiles(manifestSkills) {
|
|
830
715
|
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.
|
|
716
|
+
// Manifest paths are forward-slash by contract (lib/verify.js validateSkillPath
|
|
717
|
+
// rejects backslashes), so normalise rather than splitting on path.sep.
|
|
836
718
|
const referenced = new Set(
|
|
837
719
|
manifestSkills.map((s) => String(s.path).replace(/\\/g, '/')),
|
|
838
720
|
);
|
|
@@ -848,17 +730,9 @@ function findOrphanSkillFiles(manifestSkills) {
|
|
|
848
730
|
return orphans;
|
|
849
731
|
}
|
|
850
732
|
|
|
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.
|
|
733
|
+
// Manifest cover arrays that must resolve to a real catalog entry, paired with
|
|
734
|
+
// the loadContext() key set. These manifest-only refs feed build-indexes'
|
|
735
|
+
// reverse-ref surface and refresh-reverse-refs; the frontmatter pass never sees them.
|
|
862
736
|
const MANIFEST_COVER_RESOLUTION = [
|
|
863
737
|
{ field: 'atlas_refs', ctxKey: 'atlasKeys', catalog: 'data/atlas-ttps.json' },
|
|
864
738
|
{ field: 'attack_refs', ctxKey: 'attackKeys', catalog: 'data/attack-techniques.json' },
|
|
@@ -870,18 +744,9 @@ const MANIFEST_COVER_RESOLUTION = [
|
|
|
870
744
|
];
|
|
871
745
|
|
|
872
746
|
/*
|
|
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
|
|
747
|
+
* Assert every ref in each manifest entry's cover arrays resolves in the
|
|
748
|
+
* matching catalog. A null ctx key set — an absent optional catalog — skips
|
|
749
|
+
* that field.
|
|
885
750
|
*/
|
|
886
751
|
function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
|
|
887
752
|
const errors = [];
|
|
@@ -901,11 +766,8 @@ function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
|
|
|
901
766
|
return errors;
|
|
902
767
|
}
|
|
903
768
|
|
|
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.
|
|
769
|
+
// Substrings that mark an artifact `source` as a network call. Deliberately
|
|
770
|
+
// broad: a hit only warns, and an air_gap_alternative silences it.
|
|
909
771
|
const PLAYBOOK_NET_PATTERNS = [
|
|
910
772
|
'https://', 'http://', 'gh api', 'gh release', 'curl ', 'wget ', 'fetch ',
|
|
911
773
|
];
|
|
@@ -913,16 +775,10 @@ const PLAYBOOK_NET_PATTERNS = [
|
|
|
913
775
|
const PLAYBOOK_DIR = path.join(DATA_DIR, 'playbooks');
|
|
914
776
|
|
|
915
777
|
/**
|
|
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.
|
|
778
|
+
* Warn on any data/playbooks/*.json look artifact whose `source` makes a network
|
|
779
|
+
* call without a sibling `air_gap_alternative`. The schema enforces that only for
|
|
780
|
+
* `_meta.air_gap_mode: true`, but any playbook can run under `--air-gap`. Returns
|
|
781
|
+
* `{ playbook, artifact_id, source }` records.
|
|
926
782
|
*/
|
|
927
783
|
function lintPlaybookAirGap() {
|
|
928
784
|
const warnings = [];
|
|
@@ -956,6 +812,10 @@ function lintPlaybookAirGap() {
|
|
|
956
812
|
|
|
957
813
|
function main() {
|
|
958
814
|
const opts = parseArgs(process.argv);
|
|
815
|
+
if (opts.helpExitCode !== null) {
|
|
816
|
+
safeExit(opts.helpExitCode);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
959
819
|
const manifest = readJson(MANIFEST_PATH);
|
|
960
820
|
|
|
961
821
|
let skills = manifest.skills;
|
|
@@ -963,7 +823,8 @@ function main() {
|
|
|
963
823
|
skills = skills.filter((s) => s.name === opts.skill);
|
|
964
824
|
if (skills.length === 0) {
|
|
965
825
|
console.error(`No skill named "${opts.skill}" in manifest.json`);
|
|
966
|
-
|
|
826
|
+
safeExit(2);
|
|
827
|
+
return;
|
|
967
828
|
}
|
|
968
829
|
}
|
|
969
830
|
|
|
@@ -995,9 +856,7 @@ function main() {
|
|
|
995
856
|
}
|
|
996
857
|
}
|
|
997
858
|
|
|
998
|
-
//
|
|
999
|
-
// A targeted single-skill lint is for diagnosing one entry; running
|
|
1000
|
-
// the orphan walk there would surface unrelated findings.
|
|
859
|
+
// These passes run only on a full lint; a --skill run would show unrelated noise.
|
|
1001
860
|
let orphans = [];
|
|
1002
861
|
let manifestRefErrors = [];
|
|
1003
862
|
let airGapWarnings = [];
|
|
@@ -1008,19 +867,14 @@ function main() {
|
|
|
1008
867
|
console.log(` - skill.md exists on disk but not in manifest: ${o}`);
|
|
1009
868
|
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
869
|
}
|
|
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.
|
|
870
|
+
// Hard Rule #4, no orphaned controls: an unresolved control ref is
|
|
871
|
+
// unconditionally wrong, so this fails rather than warning under --strict.
|
|
1017
872
|
manifestRefErrors = findUnresolvedManifestCoverRefs(manifest.skills, ctx);
|
|
1018
873
|
for (const e of manifestRefErrors) {
|
|
1019
874
|
console.log(`FAIL <manifest-cover-ref>`);
|
|
1020
875
|
console.log(` - ${e}`);
|
|
1021
876
|
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
877
|
}
|
|
1023
|
-
// P4 — air-gap completeness lint over data/playbooks/*.json.
|
|
1024
878
|
airGapWarnings = lintPlaybookAirGap();
|
|
1025
879
|
for (const w of airGapWarnings) {
|
|
1026
880
|
console.log(`WARN playbook:${w.playbook}`);
|
|
@@ -1043,8 +897,6 @@ function main() {
|
|
|
1043
897
|
console.log(
|
|
1044
898
|
`\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${manifestRefSummary}${airGapSummary}.`,
|
|
1045
899
|
);
|
|
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
900
|
const strictFail = opts.strict && (warned > 0 || (airGapWarnings && airGapWarnings.length > 0));
|
|
1049
901
|
if (strictFail) {
|
|
1050
902
|
console.log(`[lint-skills] --strict: ${warned + (airGapWarnings ? airGapWarnings.length : 0)} warning(s) treated as failures.`);
|
|
@@ -1053,9 +905,9 @@ function main() {
|
|
|
1053
905
|
return;
|
|
1054
906
|
}
|
|
1055
907
|
|
|
1056
|
-
//
|
|
1057
|
-
// (e.g., orchestrator `watchlist` command) so they don't reinvent it.
|
|
908
|
+
// The frontmatter parser is exported so `watchlist` does not grow a second one.
|
|
1058
909
|
module.exports = {
|
|
910
|
+
parseArgs,
|
|
1059
911
|
parseFrontmatter,
|
|
1060
912
|
extractFrontmatterBlock,
|
|
1061
913
|
unquote,
|