@blamejs/exceptd-skills 0.13.0 → 0.13.2

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 (40) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/bin/exceptd.js +35 -6
  3. package/data/_indexes/_meta.json +26 -26
  4. package/data/_indexes/activity-feed.json +3 -3
  5. package/data/_indexes/catalog-summaries.json +3 -3
  6. package/data/_indexes/chains.json +2868 -700
  7. package/data/_indexes/frequency.json +8 -0
  8. package/data/_indexes/section-offsets.json +517 -517
  9. package/data/_indexes/token-budget.json +66 -66
  10. package/data/atlas-ttps.json +3 -0
  11. package/data/attack-techniques.json +35 -7
  12. package/data/cve-catalog.json +177 -31
  13. package/data/cwe-catalog.json +26 -6
  14. package/data/framework-control-gaps.json +310 -8
  15. package/data/zeroday-lessons.json +996 -0
  16. package/lib/lint-skills.js +50 -1
  17. package/lib/refresh-external.js +7 -0
  18. package/lib/source-advisories.js +281 -0
  19. package/manifest.json +60 -60
  20. package/orchestrator/index.js +183 -1
  21. package/package.json +1 -1
  22. package/sbom.cdx.json +59 -37
  23. package/scripts/check-test-count.js +146 -0
  24. package/scripts/predeploy.js +16 -0
  25. package/skills/age-gates-child-safety/skill.md +1 -0
  26. package/skills/ai-risk-management/skill.md +1 -0
  27. package/skills/defensive-countermeasure-mapping/skill.md +1 -0
  28. package/skills/email-security-anti-phishing/skill.md +1 -0
  29. package/skills/fuzz-testing-strategy/skill.md +1 -0
  30. package/skills/mlops-security/skill.md +1 -0
  31. package/skills/ot-ics-security/skill.md +1 -0
  32. package/skills/researcher/skill.md +1 -0
  33. package/skills/sector-energy/skill.md +1 -0
  34. package/skills/sector-federal-government/skill.md +1 -0
  35. package/skills/sector-telecom/skill.md +1 -0
  36. package/skills/skill-update-loop/skill.md +1 -0
  37. package/skills/threat-model-currency/skill.md +1 -0
  38. package/skills/threat-modeling-methodology/skill.md +1 -0
  39. package/skills/webapp-security/skill.md +1 -0
  40. package/skills/zeroday-gap-learn/skill.md +1 -0
@@ -52,6 +52,7 @@ const CWE_REFS_PATH = path.join(DATA_DIR, 'cwe-catalog.json');
52
52
  const D3FEND_REFS_PATH = path.join(DATA_DIR, 'd3fend-catalog.json');
53
53
  const DLP_REFS_PATH = path.join(DATA_DIR, 'dlp-controls.json');
54
54
  const ATTACK_REFS_PATH = path.join(DATA_DIR, 'attack-techniques.json');
55
+ const CVE_CATALOG_PATH = path.join(DATA_DIR, 'cve-catalog.json');
55
56
 
56
57
  const REQUIRED_FRONTMATTER_FIELDS = [
57
58
  'name',
@@ -65,7 +66,13 @@ const REQUIRED_FRONTMATTER_FIELDS = [
65
66
  'last_threat_review',
66
67
  ];
67
68
 
68
- const OPTIONAL_FRONTMATTER_FIELDS = ['forward_watch', 'rfc_refs', 'cwe_refs', 'd3fend_refs', 'dlp_refs'];
69
+ // v0.13.2: `discovery_mode` documents how the skill is reached by operators.
70
+ // Default (omitted) means the skill is referenced by at least one playbook's
71
+ // direct.skill_chain. `standalone` means the skill is reached via
72
+ // `exceptd brief <name>` or `exceptd ask` routing and is NOT chained — this
73
+ // closes the v0.12 audit gap that 16 skills had no playbook chain pointing
74
+ // at them. Operator intent now explicit; not a sign of orphan-skill drift.
75
+ const OPTIONAL_FRONTMATTER_FIELDS = ['forward_watch', 'rfc_refs', 'cwe_refs', 'd3fend_refs', 'dlp_refs', 'discovery_mode'];
69
76
 
70
77
  const ALL_KNOWN_FIELDS = new Set([
71
78
  ...REQUIRED_FRONTMATTER_FIELDS,
@@ -566,6 +573,39 @@ function lintSkill(entry, ctx) {
566
573
  }
567
574
  }
568
575
 
576
+ // v0.13.2 — Hard Rule #1 enforcement at the skill-body layer. Every
577
+ // CVE-* / MAL-* mentioned in skill prose SHOULD resolve to an entry
578
+ // in data/cve-catalog.json. Hard Rule #1 ("no stale threat intel")
579
+ // is enforced for catalog ENTRIES by lib/validate-cve-catalog.js —
580
+ // but a skill body that cites a CVE not in the catalog is the
581
+ // stale-intel surface Hard Rule #1 calls out at the prose layer.
582
+ //
583
+ // v0.13.2 ships these as WARNINGS so the forcing function lands
584
+ // without breaking existing skill content (2 pre-existing violations:
585
+ // ransomware-response cites CVE-2024-21762, cloud-iam-incident cites
586
+ // CVE-2026-21370). v0.14.0 will flip body-cites-unknown-CVE to a
587
+ // hard error once those two have been triaged.
588
+ if (ctx.cveCatalog && body && typeof body === 'string') {
589
+ const cveRefRe = /\b(CVE-(?:19|20)\d{2}-\d{4,7}|MAL-\d{4}-[A-Z0-9-]+)\b/g;
590
+ const seen = new Set();
591
+ let m;
592
+ while ((m = cveRefRe.exec(body)) !== null) {
593
+ const id = m[1];
594
+ if (seen.has(id)) continue;
595
+ seen.add(id);
596
+ const entry = ctx.cveCatalog[id];
597
+ if (!entry) {
598
+ skillWarnings.push(
599
+ `body cites "${id}" but no such entry in data/cve-catalog.json (Hard Rule #1 — no stale threat intel; will hard-fail in v0.14.0)`,
600
+ );
601
+ } else if (entry._draft === true) {
602
+ skillWarnings.push(
603
+ `body cites "${id}" which is _draft:true in data/cve-catalog.json — promote to verified before next release or remove from body (Hard Rule #1)`,
604
+ );
605
+ }
606
+ }
607
+ }
608
+
569
609
  // L3 — Defensive Countermeasure Mapping is required for skills reviewed
570
610
  // on or after COUNTERMEASURE_CUTOFF. Pre-cutoff skills are exempt. The
571
611
  // section's absence on a post-cutoff skill is a WARNING in v0.12.12 so
@@ -631,6 +671,14 @@ function loadContext() {
631
671
  const j = readJson(ATTACK_REFS_PATH);
632
672
  for (const k of Object.keys(j)) if (!k.startsWith('_')) attackKeys.add(k);
633
673
  }
674
+ // v0.13.2: load the CVE catalog into context so the Hard Rule #1
675
+ // body-scan can resolve CVE-* / MAL-* references in skill prose
676
+ // against the source-of-truth catalog. Loaded as a full object (not
677
+ // just keys) so the body-scan can also surface `_draft: true` matches
678
+ // as warnings rather than errors — operators promote drafts on their
679
+ // own cadence.
680
+ const cveCatalog = fs.existsSync(CVE_CATALOG_PATH) ? readJson(CVE_CATALOG_PATH) : {};
681
+
634
682
  return {
635
683
  atlasKeys,
636
684
  frameworkKeys,
@@ -639,6 +687,7 @@ function loadContext() {
639
687
  d3fendKeys: loadKeys(D3FEND_REFS_PATH),
640
688
  dlpKeys: loadKeys(DLP_REFS_PATH),
641
689
  attackKeys,
690
+ cveCatalog,
642
691
  };
643
692
  }
644
693
 
@@ -637,6 +637,12 @@ const OSV_SOURCE = {
637
637
  },
638
638
  };
639
639
 
640
+ // v0.13.1: ADVISORIES_SOURCE polls Qualys TRU + RHSA + USN + ZDI primary
641
+ // feeds and surfaces CVE IDs not yet in the catalog. Report-only — no
642
+ // auto-catalog mutation. Closes the post-mortem gap on CVE-2026-46333
643
+ // (ssh-keysign-pwn) where the existing NVD-based pollers lagged by 3+ days.
644
+ const { ADVISORIES_SOURCE } = require('./source-advisories');
645
+
640
646
  const ALL_SOURCES = {
641
647
  kev: KEV_SOURCE,
642
648
  epss: EPSS_SOURCE,
@@ -645,6 +651,7 @@ const ALL_SOURCES = {
645
651
  pins: PINS_SOURCE,
646
652
  ghsa: GHSA_SOURCE,
647
653
  osv: OSV_SOURCE,
654
+ advisories: ADVISORIES_SOURCE,
648
655
  };
649
656
 
650
657
  // --- Cache-mode helpers ------------------------------------------------
@@ -0,0 +1,281 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/source-advisories.js — primary-source advisory-feed polling.
5
+ *
6
+ * Why this exists. The post-mortem on CVE-2026-46333 (ssh-keysign-pwn,
7
+ * disclosed 2026-05-14, missed by the toolkit at T+0 through T+3) found
8
+ * that the existing source set (kev, epss, nvd, rfc, pins, ghsa, osv)
9
+ * sits at the END of the disclosure pipeline. Qualys → kernel.org commit
10
+ * → distro advisory → NVD enrichment is sequential; the existing pollers
11
+ * only see the last step, which lags by 3-14 days.
12
+ *
13
+ * The 4 feeds below sit much earlier in the pipeline:
14
+ *
15
+ * Qualys TRU RSS — Qualys-disclosed CVEs at T+0 (the originator of
16
+ * the ssh-keysign-pwn class of disclosure)
17
+ * Red Hat RHSA RSS — RHEL security advisories at T+1, often before NVD
18
+ * Ubuntu USN RSS — Ubuntu security notices at T+1, often before NVD
19
+ * ZDI advisories — Zero Day Initiative + Pwn2Own disclosures at T+0
20
+ *
21
+ * Behaviour. Each call returns a structured REPORT — not a catalog mutation.
22
+ * Operators consume the report via `exceptd refresh --check-advisories` and
23
+ * decide which advisories warrant a `refresh --advisory <CVE-ID>` auto-import
24
+ * to seed a draft entry. The report is informational; nothing is auto-written
25
+ * to the catalog. This is the conservative-by-default contract: primary-source
26
+ * surfacing must not silently mutate the catalog without operator triage.
27
+ *
28
+ * Output shape:
29
+ * {
30
+ * status: 'ok' | 'partial' | 'unreachable',
31
+ * diffs: [
32
+ * { id: 'CVE-2026-46333', source: 'qualys', advisory_url: '...',
33
+ * disclosed_at: '2026-05-14', title: '...', in_catalog: false }
34
+ * ],
35
+ * summary: '4/4 feeds reachable; 3 new CVE references found'
36
+ * }
37
+ *
38
+ * Each diff is read-only — there is no `applyDiff` that writes the catalog.
39
+ * That's by design: a fresh advisory from a primary source has insufficient
40
+ * fields to satisfy Hard Rule #1 (CVSS / KEV / PoC / AI-discovery /
41
+ * active-exploitation / patch-availability). The operator routes the
42
+ * promising ones through `refresh --advisory <CVE-ID>` which goes through
43
+ * the existing GHSA / OSV / NVD enrichment path (those pollers are mature).
44
+ *
45
+ * Cache mode (--from-cache <dir>): expected cache layout is
46
+ * <dir>/advisories/<feed>.xml — caller passes `ctx.cacheDir`.
47
+ * Fixture mode: `ctx.fixtures.advisories = { qualys: '<xml>', ... }`.
48
+ */
49
+
50
+ const path = require('path');
51
+ const fs = require('fs');
52
+
53
+ const TODAY = new Date().toISOString().slice(0, 10);
54
+
55
+ // Feed registry. Each entry has a kind (rss | json), a URL, and a parser.
56
+ // Parsers return [{ cve_ids: [...], title, link, published }, ...].
57
+ const FEEDS = [
58
+ {
59
+ name: 'qualys',
60
+ url: 'https://blog.qualys.com/category/vulnerability-research/feed',
61
+ kind: 'rss',
62
+ description: 'Qualys Threat Research Unit blog — originator of high-impact disclosures (ssh-keysign-pwn class)',
63
+ },
64
+ {
65
+ name: 'rhsa',
66
+ url: 'https://access.redhat.com/security/data/csaf/v2/advisories/2026/index.txt',
67
+ kind: 'csaf-index',
68
+ description: 'Red Hat CSAF v2 advisory index — RHEL security advisories with NVD-class enrichment at T+1',
69
+ },
70
+ {
71
+ name: 'usn',
72
+ url: 'https://ubuntu.com/security/notices/rss.xml',
73
+ kind: 'rss',
74
+ description: 'Ubuntu USN RSS — Ubuntu security notices, typically published 1-2 days post-disclosure',
75
+ },
76
+ {
77
+ name: 'zdi',
78
+ url: 'https://www.zerodayinitiative.com/rss/published/',
79
+ kind: 'rss',
80
+ description: 'Zero Day Initiative — vendor-acknowledged advisories from ZDI + Pwn2Own pipeline',
81
+ },
82
+ ];
83
+
84
+ // Permissive CVE-ID matcher. The official format is CVE-YYYY-NNNN+ but
85
+ // some feeds embed CVEs in arbitrary surrounding markup AND occasionally
86
+ // emit lowercase "cve-yyyy-nnnn" in URLs or filenames. Case-insensitive
87
+ // match, then uppercase + dedupe in extractCveIds().
88
+ const CVE_RE = /CVE-(?:19|20)\d{2}-\d{4,7}/gi;
89
+
90
+ /**
91
+ * Extract CVE IDs from a string blob. De-duplicates within the blob.
92
+ */
93
+ function extractCveIds(text) {
94
+ if (typeof text !== 'string' || text.length === 0) return [];
95
+ const matches = text.match(CVE_RE);
96
+ if (!matches) return [];
97
+ return [...new Set(matches.map((s) => s.toUpperCase()))];
98
+ }
99
+
100
+ /**
101
+ * Lightweight RSS / Atom parser. Avoids pulling in a dependency for what
102
+ * is effectively `<item>` / `<entry>` extraction + `<title>` / `<link>` /
103
+ * `<pubDate>` / `<published>` / `<description>` / `<content>` text grabs.
104
+ *
105
+ * Returns [{ title, link, published, body }, ...].
106
+ */
107
+ function parseRssAtom(xml) {
108
+ if (typeof xml !== 'string') return [];
109
+ const items = [];
110
+ // Try Atom <entry>...</entry> first.
111
+ const atomEntryRe = /<entry\b[\s\S]*?<\/entry>/g;
112
+ const rssItemRe = /<item\b[\s\S]*?<\/item>/g;
113
+ const blocks = (xml.match(atomEntryRe) || xml.match(rssItemRe) || []);
114
+ for (const block of blocks) {
115
+ const title = matchInner(block, 'title') || '';
116
+ const link = matchInner(block, 'link') || matchAttr(block, 'link', 'href') || '';
117
+ const published = matchInner(block, 'pubDate') || matchInner(block, 'published') || matchInner(block, 'updated') || '';
118
+ const description = matchInner(block, 'description') || matchInner(block, 'content') || matchInner(block, 'summary') || '';
119
+ items.push({ title: stripCdata(title), link: stripCdata(link), published: stripCdata(published), body: stripCdata(description) });
120
+ }
121
+ return items;
122
+ }
123
+
124
+ function matchInner(block, tag) {
125
+ const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
126
+ const m = block.match(re);
127
+ return m ? m[1].trim() : null;
128
+ }
129
+
130
+ function matchAttr(block, tag, attr) {
131
+ const re = new RegExp(`<${tag}[^>]*\\b${attr}=["']([^"']+)["']`, 'i');
132
+ const m = block.match(re);
133
+ return m ? m[1] : null;
134
+ }
135
+
136
+ function stripCdata(s) {
137
+ if (typeof s !== 'string') return '';
138
+ return s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
139
+ }
140
+
141
+ /**
142
+ * CSAF index parser — Red Hat ships a plain-text index of advisory JSON
143
+ * files under data/csaf/v2/advisories/YYYY/index.txt. Each line is a
144
+ * relative filename. We don't fetch the per-advisory JSON in v0.13.1
145
+ * (would blow the polling budget); we surface the advisory IDs that
146
+ * mention CVE-YYYY-NNNN inline.
147
+ */
148
+ function parseCsafIndex(text) {
149
+ if (typeof text !== 'string') return [];
150
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
151
+ return lines.map((line) => {
152
+ const cves = extractCveIds(line);
153
+ return { title: line.trim(), link: '', published: '', body: '', cves_from_filename: cves };
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Fetch a feed body. In fixture / cache modes, read from disk.
159
+ */
160
+ async function fetchFeed(feed, ctx) {
161
+ if (ctx.fixtures && ctx.fixtures.advisories && ctx.fixtures.advisories[feed.name]) {
162
+ return { ok: true, body: ctx.fixtures.advisories[feed.name] };
163
+ }
164
+ if (ctx.cacheDir) {
165
+ const ext = feed.kind === 'csaf-index' ? '.txt' : '.xml';
166
+ const p = path.join(ctx.cacheDir, 'advisories', `${feed.name}${ext}`);
167
+ if (!fs.existsSync(p)) return { ok: false, error: `cache miss: ${p}` };
168
+ return { ok: true, body: fs.readFileSync(p, 'utf8') };
169
+ }
170
+ if (typeof fetch !== 'function') return { ok: false, error: 'fetch() not available — Node 18+ required' };
171
+ try {
172
+ const ac = new AbortController();
173
+ const timer = setTimeout(() => ac.abort(), 8000);
174
+ const r = await fetch(feed.url, { signal: ac.signal, headers: { 'User-Agent': 'exceptd-advisories-poller/0.13.1 (+https://exceptd.com)' } });
175
+ clearTimeout(timer);
176
+ if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
177
+ return { ok: true, body: await r.text() };
178
+ } catch (e) {
179
+ return { ok: false, error: e.message || String(e) };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Walk one feed: fetch, parse, extract CVE IDs, compare to local catalog.
185
+ * Returns { diffs, errors, status }.
186
+ */
187
+ async function checkFeed(feed, ctx) {
188
+ const res = await fetchFeed(feed, ctx);
189
+ if (!res.ok) return { diffs: [], errors: 1, status: 'unreachable', _why: res.error };
190
+ let items;
191
+ if (feed.kind === 'csaf-index') {
192
+ items = parseCsafIndex(res.body);
193
+ // Flatten cves_from_filename onto cve_ids field uniformly.
194
+ items = items.map((it) => ({ ...it, cve_ids: it.cves_from_filename || [] }));
195
+ } else {
196
+ items = parseRssAtom(res.body);
197
+ items = items.map((it) => ({ ...it, cve_ids: extractCveIds(`${it.title} ${it.body} ${it.link}`) }));
198
+ }
199
+ const diffs = [];
200
+ for (const it of items) {
201
+ for (const cveId of it.cve_ids) {
202
+ const inCatalog = !!ctx.cveCatalog[cveId];
203
+ if (!inCatalog) {
204
+ diffs.push({
205
+ id: cveId,
206
+ source: feed.name,
207
+ advisory_url: it.link || feed.url,
208
+ disclosed_at: it.published || null,
209
+ title: it.title.slice(0, 200),
210
+ in_catalog: false,
211
+ });
212
+ }
213
+ }
214
+ }
215
+ return { diffs, errors: 0, status: 'ok' };
216
+ }
217
+
218
+ /**
219
+ * The exported SOURCE definition, matching the shape ALL_SOURCES expects.
220
+ */
221
+ const ADVISORIES_SOURCE = {
222
+ name: 'advisories',
223
+ description: 'Primary-source advisory feeds (Qualys TRU, Red Hat RHSA, Ubuntu USN, Zero Day Initiative / ZDI) — surfaces CVE IDs disclosed at T+0 to T+1 that lag NVD enrichment. Report-only — does not auto-write the catalog.',
224
+ applies_to: 'data/cve-catalog.json',
225
+ async fetchDiff(ctx) {
226
+ const results = await Promise.all(FEEDS.map((feed) => checkFeed(feed, ctx)));
227
+ const allDiffs = [];
228
+ let unreachable = 0;
229
+ for (const r of results) {
230
+ allDiffs.push(...r.diffs);
231
+ if (r.status === 'unreachable') unreachable++;
232
+ }
233
+ // Deduplicate by CVE-ID across feeds — multiple advisories for the
234
+ // same CVE collapse to one entry with sources[] array of contributing
235
+ // feed names.
236
+ const byCve = new Map();
237
+ for (const d of allDiffs) {
238
+ if (!byCve.has(d.id)) {
239
+ byCve.set(d.id, { ...d, sources: [d.source], advisory_urls: [d.advisory_url] });
240
+ } else {
241
+ const existing = byCve.get(d.id);
242
+ if (!existing.sources.includes(d.source)) existing.sources.push(d.source);
243
+ if (!existing.advisory_urls.includes(d.advisory_url)) existing.advisory_urls.push(d.advisory_url);
244
+ }
245
+ }
246
+ const diffs = Array.from(byCve.values()).map((d) => {
247
+ delete d.source;
248
+ delete d.advisory_url;
249
+ return d;
250
+ });
251
+ const status =
252
+ unreachable === 0 ? 'ok' :
253
+ unreachable === FEEDS.length ? 'unreachable' : 'partial';
254
+ return {
255
+ status,
256
+ diffs,
257
+ errors: unreachable,
258
+ summary: `${FEEDS.length - unreachable}/${FEEDS.length} feeds reachable; ${diffs.length} new CVE references found across primary advisory sources`,
259
+ };
260
+ },
261
+ // Report-only: no applyDiff. Operators route promising CVE IDs through
262
+ // `exceptd refresh --advisory <CVE-ID>` (GHSA / OSV / NVD enrichment).
263
+ applyDiff(_ctx, _diffs) {
264
+ return {
265
+ updated: 0,
266
+ added: 0,
267
+ drift_updated: 0,
268
+ errors: [],
269
+ note: 'ADVISORIES_SOURCE is report-only. Route promising IDs through `exceptd refresh --advisory <CVE-ID>` to auto-import a draft via the GHSA / OSV / NVD enrichment pipeline.',
270
+ };
271
+ },
272
+ };
273
+
274
+ module.exports = {
275
+ ADVISORIES_SOURCE,
276
+ // Exposed for tests + future schedule-agent reuse:
277
+ FEEDS,
278
+ extractCveIds,
279
+ parseRssAtom,
280
+ parseCsafIndex,
281
+ };