@blamejs/exceptd-skills 0.9.1

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 (136) hide show
  1. package/AGENTS.md +232 -0
  2. package/ARCHITECTURE.md +267 -0
  3. package/CHANGELOG.md +616 -0
  4. package/CONTEXT.md +203 -0
  5. package/LICENSE +200 -0
  6. package/NOTICE +82 -0
  7. package/README.md +307 -0
  8. package/SECURITY.md +73 -0
  9. package/agents/README.md +81 -0
  10. package/agents/report-generator.md +156 -0
  11. package/agents/skill-updater.md +102 -0
  12. package/agents/source-validator.md +119 -0
  13. package/agents/threat-researcher.md +149 -0
  14. package/bin/exceptd.js +183 -0
  15. package/data/_indexes/_meta.json +88 -0
  16. package/data/_indexes/activity-feed.json +362 -0
  17. package/data/_indexes/catalog-summaries.json +229 -0
  18. package/data/_indexes/chains.json +7135 -0
  19. package/data/_indexes/currency.json +359 -0
  20. package/data/_indexes/did-ladders.json +451 -0
  21. package/data/_indexes/frequency.json +2072 -0
  22. package/data/_indexes/handoff-dag.json +476 -0
  23. package/data/_indexes/jurisdiction-clocks.json +967 -0
  24. package/data/_indexes/jurisdiction-map.json +536 -0
  25. package/data/_indexes/recipes.json +319 -0
  26. package/data/_indexes/section-offsets.json +3656 -0
  27. package/data/_indexes/stale-content.json +14 -0
  28. package/data/_indexes/summary-cards.json +1736 -0
  29. package/data/_indexes/theater-fingerprints.json +381 -0
  30. package/data/_indexes/token-budget.json +2137 -0
  31. package/data/_indexes/trigger-table.json +1374 -0
  32. package/data/_indexes/xref.json +818 -0
  33. package/data/atlas-ttps.json +282 -0
  34. package/data/cve-catalog.json +496 -0
  35. package/data/cwe-catalog.json +1017 -0
  36. package/data/d3fend-catalog.json +738 -0
  37. package/data/dlp-controls.json +1039 -0
  38. package/data/exploit-availability.json +67 -0
  39. package/data/framework-control-gaps.json +1255 -0
  40. package/data/global-frameworks.json +2913 -0
  41. package/data/rfc-references.json +324 -0
  42. package/data/zeroday-lessons.json +377 -0
  43. package/keys/public.pem +3 -0
  44. package/lib/framework-gap.js +328 -0
  45. package/lib/job-queue.js +195 -0
  46. package/lib/lint-skills.js +536 -0
  47. package/lib/prefetch.js +372 -0
  48. package/lib/refresh-external.js +713 -0
  49. package/lib/schemas/cve-catalog.schema.json +151 -0
  50. package/lib/schemas/manifest.schema.json +106 -0
  51. package/lib/schemas/skill-frontmatter.schema.json +113 -0
  52. package/lib/scoring.js +149 -0
  53. package/lib/sign.js +197 -0
  54. package/lib/ttp-mapper.js +80 -0
  55. package/lib/validate-catalog-meta.js +198 -0
  56. package/lib/validate-cve-catalog.js +213 -0
  57. package/lib/validate-indexes.js +83 -0
  58. package/lib/validate-package.js +162 -0
  59. package/lib/validate-vendor.js +85 -0
  60. package/lib/verify.js +216 -0
  61. package/lib/worker-pool.js +84 -0
  62. package/manifest-snapshot.json +1833 -0
  63. package/manifest.json +2108 -0
  64. package/orchestrator/README.md +124 -0
  65. package/orchestrator/dispatcher.js +140 -0
  66. package/orchestrator/event-bus.js +146 -0
  67. package/orchestrator/index.js +874 -0
  68. package/orchestrator/pipeline.js +201 -0
  69. package/orchestrator/scanner.js +327 -0
  70. package/orchestrator/scheduler.js +137 -0
  71. package/package.json +113 -0
  72. package/sbom.cdx.json +158 -0
  73. package/scripts/audit-cross-skill.js +261 -0
  74. package/scripts/audit-perf.js +160 -0
  75. package/scripts/bootstrap.js +205 -0
  76. package/scripts/build-indexes.js +721 -0
  77. package/scripts/builders/activity-feed.js +79 -0
  78. package/scripts/builders/catalog-summaries.js +67 -0
  79. package/scripts/builders/currency.js +109 -0
  80. package/scripts/builders/cwe-chains.js +105 -0
  81. package/scripts/builders/did-ladders.js +149 -0
  82. package/scripts/builders/frequency.js +89 -0
  83. package/scripts/builders/jurisdiction-clocks.js +126 -0
  84. package/scripts/builders/recipes.js +159 -0
  85. package/scripts/builders/section-offsets.js +162 -0
  86. package/scripts/builders/stale-content.js +171 -0
  87. package/scripts/builders/summary-cards.js +166 -0
  88. package/scripts/builders/theater-fingerprints.js +198 -0
  89. package/scripts/builders/token-budget.js +96 -0
  90. package/scripts/check-manifest-snapshot.js +217 -0
  91. package/scripts/predeploy.js +267 -0
  92. package/scripts/refresh-manifest-snapshot.js +57 -0
  93. package/scripts/refresh-sbom.js +222 -0
  94. package/skills/age-gates-child-safety/skill.md +456 -0
  95. package/skills/ai-attack-surface/skill.md +282 -0
  96. package/skills/ai-c2-detection/skill.md +440 -0
  97. package/skills/ai-risk-management/skill.md +311 -0
  98. package/skills/api-security/skill.md +287 -0
  99. package/skills/attack-surface-pentest/skill.md +381 -0
  100. package/skills/cloud-security/skill.md +384 -0
  101. package/skills/compliance-theater/skill.md +365 -0
  102. package/skills/container-runtime-security/skill.md +379 -0
  103. package/skills/coordinated-vuln-disclosure/skill.md +473 -0
  104. package/skills/defensive-countermeasure-mapping/skill.md +300 -0
  105. package/skills/dlp-gap-analysis/skill.md +337 -0
  106. package/skills/email-security-anti-phishing/skill.md +206 -0
  107. package/skills/exploit-scoring/skill.md +331 -0
  108. package/skills/framework-gap-analysis/skill.md +374 -0
  109. package/skills/fuzz-testing-strategy/skill.md +313 -0
  110. package/skills/global-grc/skill.md +564 -0
  111. package/skills/identity-assurance/skill.md +272 -0
  112. package/skills/incident-response-playbook/skill.md +546 -0
  113. package/skills/kernel-lpe-triage/skill.md +303 -0
  114. package/skills/mcp-agent-trust/skill.md +326 -0
  115. package/skills/mlops-security/skill.md +325 -0
  116. package/skills/ot-ics-security/skill.md +340 -0
  117. package/skills/policy-exception-gen/skill.md +437 -0
  118. package/skills/pqc-first/skill.md +546 -0
  119. package/skills/rag-pipeline-security/skill.md +294 -0
  120. package/skills/researcher/skill.md +310 -0
  121. package/skills/sector-energy/skill.md +409 -0
  122. package/skills/sector-federal-government/skill.md +302 -0
  123. package/skills/sector-financial/skill.md +398 -0
  124. package/skills/sector-healthcare/skill.md +373 -0
  125. package/skills/security-maturity-tiers/skill.md +464 -0
  126. package/skills/skill-update-loop/skill.md +463 -0
  127. package/skills/supply-chain-integrity/skill.md +318 -0
  128. package/skills/threat-model-currency/skill.md +404 -0
  129. package/skills/threat-modeling-methodology/skill.md +312 -0
  130. package/skills/webapp-security/skill.md +281 -0
  131. package/skills/zeroday-gap-learn/skill.md +350 -0
  132. package/vendor/blamejs/LICENSE +201 -0
  133. package/vendor/blamejs/README.md +54 -0
  134. package/vendor/blamejs/_PROVENANCE.json +54 -0
  135. package/vendor/blamejs/retry.js +335 -0
  136. package/vendor/blamejs/worker-pool.js +418 -0
@@ -0,0 +1,874 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * exceptd orchestrator — CLI entry point.
6
+ *
7
+ * Commands:
8
+ * scan Scan current environment and produce findings
9
+ * dispatch Route findings to relevant skills
10
+ * skill <name> Show context for a specific skill
11
+ * pipeline Initialize and describe a pipeline run
12
+ * currency Check skill currency scores
13
+ * report Print dispatch plan as a report
14
+ * watch Start event bus watcher (long-running)
15
+ * validate-cves Remind to validate CVE entries against NVD
16
+ * validate-rfcs Cross-check the RFC catalog against IETF Datatracker
17
+ * watchlist Aggregate forward_watch entries across all skills
18
+ * help Show this help
19
+ */
20
+
21
+ const { scan } = require('./scanner');
22
+ const { dispatch, routeQuery, getSkillContext } = require('./dispatcher');
23
+ const { currencyCheck, initPipeline } = require('./pipeline');
24
+ const { bus, EVENT_TYPES } = require('./event-bus');
25
+ const { start: startScheduler, stop: stopScheduler, runCurrencyNow } = require('./scheduler');
26
+
27
+ const cmd = process.argv[2];
28
+ const args = process.argv.slice(3);
29
+
30
+ async function main() {
31
+ switch (cmd) {
32
+ case 'scan':
33
+ await runScan();
34
+ break;
35
+ case 'dispatch':
36
+ await runDispatch();
37
+ break;
38
+ case 'skill':
39
+ runSkillContext(args[0]);
40
+ break;
41
+ case 'pipeline':
42
+ runPipeline(args[0] || 'manual', args[1] ? JSON.parse(args[1]) : {});
43
+ break;
44
+ case 'currency':
45
+ runCurrency();
46
+ break;
47
+ case 'report':
48
+ await runReport(args[0] || 'technical');
49
+ break;
50
+ case 'watch':
51
+ runWatch();
52
+ break;
53
+ case 'validate-cves':
54
+ await runValidateCves(args);
55
+ break;
56
+ case 'validate-rfcs':
57
+ await runValidateRfcs(args);
58
+ break;
59
+ case 'watchlist':
60
+ runWatchlist(args);
61
+ break;
62
+ case 'help':
63
+ default:
64
+ printHelp();
65
+ }
66
+ }
67
+
68
+ // --- command implementations ---
69
+
70
+ async function runScan() {
71
+ console.log('[orchestrator] Scanning environment...\n');
72
+ const result = await scan();
73
+
74
+ console.log('Host:', JSON.stringify(result.host, null, 2));
75
+ console.log('\nFindings by domain:');
76
+ for (const [domain, count] of Object.entries(result.summary.by_domain)) {
77
+ console.log(` ${domain}: ${count}`);
78
+ }
79
+
80
+ console.log('\nBy severity:');
81
+ for (const [severity, count] of Object.entries(result.summary.by_severity)) {
82
+ if (count > 0) console.log(` ${severity}: ${count}`);
83
+ }
84
+
85
+ console.log('\nRecommended skills:');
86
+ for (const skill of result.summary.recommended_skills) {
87
+ console.log(` - ${skill}`);
88
+ }
89
+
90
+ if (result.summary.action_required) {
91
+ console.log('\n⚠ Action required — critical or high severity findings present.');
92
+ }
93
+
94
+ console.log(`\nTotal findings: ${result.summary.total_findings}`);
95
+ console.log('Timestamp:', result.timestamp);
96
+ return result;
97
+ }
98
+
99
+ async function runDispatch() {
100
+ console.log('[orchestrator] Scanning then dispatching...\n');
101
+ const scanResult = await scan();
102
+ const plan = dispatch(scanResult.findings);
103
+
104
+ console.log(`Dispatch plan — ${plan.plan.length} skills to invoke:\n`);
105
+
106
+ for (const item of plan.plan) {
107
+ const urgency = item.priority <= 1 ? 'CRITICAL' : item.priority === 2 ? 'HIGH' : 'MEDIUM';
108
+ console.log(`[${urgency}] ${item.skill_name}`);
109
+ console.log(` Triggered by: ${item.triggered_by} (${item.finding_domain})`);
110
+ console.log(` Action: ${item.action_required}`);
111
+ console.log(` Path: ${item.skill_path}`);
112
+ console.log();
113
+ }
114
+
115
+ if (plan.unmatched.length > 0) {
116
+ console.log(`Unmatched findings (${plan.unmatched.length}):`);
117
+ for (const f of plan.unmatched) console.log(` - ${f.signal} (${f.domain})`);
118
+ }
119
+
120
+ return plan;
121
+ }
122
+
123
+ function runSkillContext(skillName) {
124
+ if (!skillName) {
125
+ console.error('Usage: node orchestrator/index.js skill <skill-name>');
126
+ process.exit(1);
127
+ }
128
+
129
+ const context = getSkillContext(skillName);
130
+ if (!context) {
131
+ console.error(`Skill not found: ${skillName}`);
132
+ process.exit(1);
133
+ }
134
+
135
+ console.log(`Skill: ${context.skill.name} v${context.skill.version}`);
136
+ console.log(`Description: ${context.skill.description}`);
137
+ console.log(`\nTriggers: ${context.skill.triggers?.join(', ')}`);
138
+ console.log(`\nData dependencies:`);
139
+ for (const [dep, info] of Object.entries(context.data_paths)) {
140
+ console.log(` ${dep}: ${info.exists ? 'OK' : 'MISSING'}`);
141
+ }
142
+
143
+ if (context.skill_content) {
144
+ const lines = context.skill_content.split('\n').length;
145
+ console.log(`\nSkill file: ${lines} lines`);
146
+ }
147
+ }
148
+
149
+ function runPipeline(triggerType, payload) {
150
+ const run = initPipeline(triggerType, payload);
151
+ console.log(`Pipeline initialized: ${run.pipeline_id}`);
152
+ console.log(`Trigger: ${run.trigger.type}`);
153
+ console.log('\nStages:');
154
+ for (const stage of run.stages) {
155
+ console.log(` ${stage.name}: ${stage.status}`);
156
+ console.log(` Agent: ${stage.agent_path}`);
157
+ }
158
+ console.log('\nTo run each stage, load the agent definition and follow its instructions:');
159
+ console.log(' node orchestrator/index.js skill skill-update-loop');
160
+ return run;
161
+ }
162
+
163
+ function runCurrency() {
164
+ const result = runCurrencyNow();
165
+ const { currency_report, action_required, critical_count } = currencyCheck();
166
+
167
+ console.log(`\nSkill currency check — ${new Date().toISOString()}\n`);
168
+ console.log('Score | Days | Skill');
169
+ console.log('------|------|-----');
170
+ for (const s of currency_report) {
171
+ const flag = s.currency_score < 50 ? '⚠' : s.currency_score < 70 ? '!' : ' ';
172
+ console.log(`${flag} ${String(s.currency_score).padStart(3)}% | ${String(s.days_since_review).padStart(4)}d | ${s.skill}`);
173
+ }
174
+
175
+ console.log(`\n${currency_report.length} skills checked.`);
176
+ if (action_required) {
177
+ console.log(`⚠ ${critical_count} skills require immediate update (currency < 50%)`);
178
+ } else {
179
+ console.log('All skills within acceptable currency range.');
180
+ }
181
+ }
182
+
183
+ async function runReport(format) {
184
+ console.log(`[orchestrator] Generating ${format} report...\n`);
185
+ const scanResult = await scan();
186
+ const plan = dispatch(scanResult.findings);
187
+ const { currency_report } = currencyCheck();
188
+
189
+ console.log('# exceptd Security Assessment Report');
190
+ console.log(`Generated: ${new Date().toISOString()}\n`);
191
+
192
+ console.log('## Executive Summary');
193
+ console.log(`- Total scan findings: ${scanResult.summary.total_findings}`);
194
+ console.log(`- Critical findings: ${scanResult.summary.by_severity.critical}`);
195
+ console.log(`- High findings: ${scanResult.summary.by_severity.high}`);
196
+ console.log(`- Skills triggered: ${plan.plan.length}`);
197
+ console.log(`- Action required: ${scanResult.summary.action_required}\n`);
198
+
199
+ console.log('## Priority Actions');
200
+ for (const item of plan.plan.filter(p => p.priority <= 2)) {
201
+ console.log(`- [${item.finding_severity.toUpperCase()}] Run ${item.skill_name}: ${item.action_required}`);
202
+ }
203
+
204
+ console.log('\n## Skill Currency');
205
+ const stale = currency_report.filter(s => s.currency_score < 70);
206
+ if (stale.length > 0) {
207
+ console.log(`${stale.length} skills need review:`);
208
+ for (const s of stale) console.log(` - ${s.skill}: ${s.currency_score}% (${s.days_since_review}d old)`);
209
+ } else {
210
+ console.log('All skills current.');
211
+ }
212
+ }
213
+
214
+ function runWatch() {
215
+ console.log('[orchestrator] Starting event watcher...');
216
+ console.log('Listening for: CISA KEV additions, ATLAS updates, CVE drops, framework amendments.\n');
217
+
218
+ bus.onAny(event => {
219
+ console.log(`[event] ${event.type} — ${event.timestamp}`);
220
+ if (event.affected_skills.length > 0) {
221
+ console.log(` Affected skills: ${event.affected_skills.join(', ')}`);
222
+ }
223
+ if (event.payload.cve_id) {
224
+ console.log(` CVE: ${event.payload.cve_id}`);
225
+ }
226
+ });
227
+
228
+ startScheduler();
229
+
230
+ process.on('SIGINT', () => {
231
+ console.log('\n[orchestrator] Stopping watcher.');
232
+ stopScheduler();
233
+ process.exit(0);
234
+ });
235
+
236
+ console.log('Press Ctrl+C to stop.\n');
237
+ }
238
+
239
+ async function runValidateCves(rawArgs = []) {
240
+ const fs = require('fs');
241
+ const path = require('path');
242
+
243
+ const flags = new Set(rawArgs.filter(a => a.startsWith('--')));
244
+ const offline = flags.has('--offline');
245
+ const noFail = flags.has('--no-fail');
246
+ // --from-cache: prefer cached upstream snapshots before falling back to live
247
+ // network. Accepts an optional path; defaults to .cache/upstream when bare.
248
+ // The cache layout is fixed by lib/prefetch.js — same one refresh-external
249
+ // reads from.
250
+ let cacheDir = null;
251
+ for (let i = 0; i < rawArgs.length; i++) {
252
+ const a = rawArgs[i];
253
+ if (a === '--from-cache') {
254
+ const next = rawArgs[i + 1];
255
+ cacheDir = next && !next.startsWith('--') ? next : '.cache/upstream';
256
+ if (next && !next.startsWith('--')) i++;
257
+ } else if (a.startsWith('--from-cache=')) {
258
+ cacheDir = a.slice('--from-cache='.length);
259
+ }
260
+ }
261
+ if (cacheDir) cacheDir = path.resolve(cacheDir);
262
+
263
+ const catalogPath = path.join(__dirname, '..', 'data', 'cve-catalog.json');
264
+ let catalog;
265
+ try {
266
+ catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
267
+ } catch (err) {
268
+ console.error(`[validate-cves] cannot read ${catalogPath}: ${err.message}`);
269
+ process.exit(2);
270
+ }
271
+
272
+ const cveIds = Object.keys(catalog).filter(k => /^CVE-\d{4}-\d{4,7}$/.test(k));
273
+
274
+ console.log(`\nCVE Validation — ${new Date().toISOString()}`);
275
+ const modeStr = offline
276
+ ? 'offline (local view only)'
277
+ : (cacheDir ? `live with cache (${path.relative(path.join(__dirname, '..'), cacheDir)})` : 'live (NVD + CISA KEV)');
278
+ console.log(`${cveIds.length} CVEs in catalog. Mode: ${modeStr}`);
279
+ console.log(`Fail-on-drift: ${noFail ? 'disabled' : 'enabled'}\n`);
280
+
281
+ // --- Header (fixed-width; works with the existing currency command's style)
282
+ const header = 'CVE | Local RWEP | Local CVSS | NVD CVSS | KEV Local | KEV NVD | EPSS Local | EPSS Live | EPSS Drift | Status';
283
+ const rule = '-------------------|------------|------------|------------------|-----------|---------|-----------------|-----------------|------------|----------';
284
+ console.log(header);
285
+ console.log(rule);
286
+
287
+ function fmt(v, n) {
288
+ const s = (v === null || v === undefined) ? '-' : String(v);
289
+ return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length);
290
+ }
291
+
292
+ // Format an EPSS pair as "score / percentile" with 4-decimal score, 2-decimal pct.
293
+ function fmtEpss(score, pct) {
294
+ if (score === null || score === undefined) return '-';
295
+ const s = Number(score).toFixed(4);
296
+ const p = (pct === null || pct === undefined) ? '?' : Number(pct).toFixed(2);
297
+ return `${s}/${p}`;
298
+ }
299
+
300
+ if (offline) {
301
+ for (const id of cveIds) {
302
+ const e = catalog[id];
303
+ console.log(
304
+ fmt(id, 18) + ' | ' +
305
+ fmt(e.rwep_score, 10) + ' | ' +
306
+ fmt(e.cvss_score, 10) + ' | ' +
307
+ fmt('(offline)', 16) + ' | ' +
308
+ fmt(e.cisa_kev, 9) + ' | ' +
309
+ fmt('(offline)', 7) + ' | ' +
310
+ fmt(fmtEpss(e.epss_score, e.epss_percentile), 15) + ' | ' +
311
+ fmt('(offline)', 15) + ' | ' +
312
+ fmt('(offline)', 10) + ' | ' +
313
+ 'local-only'
314
+ );
315
+ }
316
+ console.log(`\n[validate-cves] offline mode — no network calls made. ${cveIds.length} entries listed from local catalog.`);
317
+ process.exit(0);
318
+ return;
319
+ }
320
+
321
+ // Live path — opportunistically use the prefetch cache when --from-cache
322
+ // is set. Cache-resolved CVEs short-circuit the network fetch; missing
323
+ // entries fall through to the live validator. Both paths produce the
324
+ // same ValidationResult shape.
325
+ const { validateAllCves } = require('../sources/validators');
326
+ let report;
327
+ if (cacheDir && fs.existsSync(cacheDir)) {
328
+ report = await validateAllCvesPreferCache(catalog, cacheDir);
329
+ } else {
330
+ report = await validateAllCves(catalog, { concurrency: 4 });
331
+ }
332
+
333
+ // Index results by cve_id (validateAllCves preserves insertion order, but be explicit).
334
+ const byId = new Map(report.results.map(r => [r.cve_id, r]));
335
+ let driftFound = 0;
336
+ let unreachable = 0;
337
+
338
+ for (const id of cveIds) {
339
+ const e = catalog[id];
340
+ const r = byId.get(id);
341
+ const status = r?.status || 'unknown';
342
+ if (status === 'drift') driftFound++;
343
+ if (status === 'unreachable') unreachable++;
344
+
345
+ const nvdScore = r?.fetched?.cvss_score ?? null;
346
+ const kevNvd = r?.fetched?.in_kev;
347
+ const kevNvdStr = (kevNvd === null || kevNvd === undefined) ? '?' : String(kevNvd);
348
+
349
+ const cvssMismatch = r?.discrepancies?.some(d => d.field === 'cvss_score');
350
+ const kevMismatch = r?.discrepancies?.some(d => d.field === 'cisa_kev');
351
+
352
+ // EPSS Local / Live / Drift block
353
+ const liveEpss = r?.fetched?.epss || null;
354
+ const epssReachable = r?.fetched?.sources?.epss?.reachable === true;
355
+ const epssMismatchScore = r?.discrepancies?.some(d => d.field === 'epss_score');
356
+ const epssMismatchPct = r?.discrepancies?.some(d => d.field === 'epss_percentile');
357
+ const localEpssCell = fmtEpss(e.epss_score, e.epss_percentile);
358
+ const liveEpssCell = liveEpss
359
+ ? fmtEpss(liveEpss.score, liveEpss.percentile)
360
+ : (epssReachable ? 'not-found' : 'unreachable');
361
+ let driftCell = '-';
362
+ if (r?.drift) {
363
+ const dScore = (liveEpss?.score !== null && e.epss_score !== null && e.epss_score !== undefined)
364
+ ? (liveEpss.score - e.epss_score)
365
+ : null;
366
+ const dPct = (liveEpss?.percentile !== null && e.epss_percentile !== null && e.epss_percentile !== undefined)
367
+ ? (liveEpss.percentile - e.epss_percentile)
368
+ : null;
369
+ const parts = [];
370
+ if (dScore !== null) parts.push(`Δs=${(dScore >= 0 ? '+' : '') + dScore.toFixed(3)}`);
371
+ if (dPct !== null) parts.push(`Δp=${(dPct >= 0 ? '+' : '') + dPct.toFixed(3)}`);
372
+ driftCell = parts.join(' ') + ' DRIFT';
373
+ } else if (epssMismatchScore || epssMismatchPct) {
374
+ driftCell = 'DRIFT';
375
+ }
376
+
377
+ console.log(
378
+ fmt(id, 18) + ' | ' +
379
+ fmt(e.rwep_score, 10) + ' | ' +
380
+ fmt(e.cvss_score, 10) + ' | ' +
381
+ fmt(nvdScore === null ? '-' : `${nvdScore}${cvssMismatch ? ' DRIFT' : ''}`, 16) + ' | ' +
382
+ fmt(e.cisa_kev, 9) + ' | ' +
383
+ fmt(`${kevNvdStr}${kevMismatch ? ' DRIFT' : ''}`, 7) + ' | ' +
384
+ fmt(localEpssCell, 15) + ' | ' +
385
+ fmt(liveEpssCell, 15) + ' | ' +
386
+ fmt(driftCell, 10) + ' | ' +
387
+ status
388
+ );
389
+
390
+ if (r?.discrepancies?.length) {
391
+ for (const d of r.discrepancies) {
392
+ console.log(` -> drift on ${d.field}: local=${JSON.stringify(d.local)} fetched=${JSON.stringify(d.fetched)} (${d.severity})`);
393
+ }
394
+ }
395
+ }
396
+
397
+ console.log(`\nSummary: match=${report.by_status.match || 0} drift=${report.by_status.drift || 0} unreachable=${report.by_status.unreachable || 0} missing=${report.by_status.missing || 0} (total=${report.total})`);
398
+ if (unreachable > 0) {
399
+ console.log(`Note: ${unreachable} CVE(s) unreachable — airgapped or upstream down. Re-run when network is available.`);
400
+ }
401
+ if (driftFound > 0) {
402
+ console.log(`\n[validate-cves] DRIFT DETECTED on ${driftFound} CVE(s). Update data/cve-catalog.json and bump source_verified.`);
403
+ if (!noFail) process.exit(1);
404
+ } else {
405
+ console.log('[validate-cves] No drift detected against reachable sources.');
406
+ }
407
+ }
408
+
409
+ /**
410
+ * validate-rfcs — companion to validate-cves for the IETF RFC / Internet-Draft
411
+ * catalog. Confirms that every entry in data/rfc-references.json is current
412
+ * against the IETF Datatracker.
413
+ *
414
+ * Modes:
415
+ * --offline Print the local view only; do not fetch. Useful for airgapped
416
+ * CI runs and for fast iteration on the catalog file itself.
417
+ * --live Fetch the IETF Datatracker for each RFC / draft (default if
418
+ * neither flag passed).
419
+ * --no-fail Report drift but exit zero. Useful when you want a quarterly
420
+ * drift report without blocking CI.
421
+ *
422
+ * Per AGENTS.md hard rule #12 (external data version pinning), drift surfaces
423
+ * are: status change (Draft → Standards Track → Internet Standard), new
424
+ * errata since `last_verified`, replaced-by relationships, and obsoletion.
425
+ * A local entry with no upstream is flagged. Network errors return
426
+ * `unreachable` for that entry — they never fail the run.
427
+ */
428
+ async function runValidateRfcs(rawArgs = []) {
429
+ const fs = require('fs');
430
+ const path = require('path');
431
+
432
+ const flags = new Set(rawArgs.filter(a => a.startsWith('--')));
433
+ const offline = flags.has('--offline');
434
+ const noFail = flags.has('--no-fail');
435
+ let cacheDir = null;
436
+ for (let i = 0; i < rawArgs.length; i++) {
437
+ const a = rawArgs[i];
438
+ if (a === '--from-cache') {
439
+ const next = rawArgs[i + 1];
440
+ cacheDir = next && !next.startsWith('--') ? next : '.cache/upstream';
441
+ if (next && !next.startsWith('--')) i++;
442
+ } else if (a.startsWith('--from-cache=')) {
443
+ cacheDir = a.slice('--from-cache='.length);
444
+ }
445
+ }
446
+ if (cacheDir) cacheDir = path.resolve(cacheDir);
447
+
448
+ const refsPath = path.join(__dirname, '..', 'data', 'rfc-references.json');
449
+ let refs;
450
+ try {
451
+ refs = JSON.parse(fs.readFileSync(refsPath, 'utf8'));
452
+ } catch (err) {
453
+ console.error(`[validate-rfcs] cannot read ${refsPath}: ${err.message}`);
454
+ process.exit(2);
455
+ }
456
+
457
+ const ids = Object.keys(refs).filter(k => !k.startsWith('_'));
458
+
459
+ console.log(`\nRFC Validation — ${new Date().toISOString()}`);
460
+ const modeStr = offline
461
+ ? 'offline (local view only)'
462
+ : (cacheDir ? `live with cache (${path.relative(path.join(__dirname, '..'), cacheDir)})` : 'live (IETF Datatracker)');
463
+ console.log(`${ids.length} RFC / draft entries in catalog. Mode: ${modeStr}`);
464
+ console.log(`Fail-on-drift: ${noFail ? 'disabled' : 'enabled'}\n`);
465
+
466
+ const header = 'ID | Status | Errata | Last verified | Live status';
467
+ const rule = '--------------------------------|----------------------|--------|---------------|---------------------';
468
+ console.log(header);
469
+ console.log(rule);
470
+
471
+ function fmt(v, n) {
472
+ const s = (v === null || v === undefined) ? '-' : String(v);
473
+ return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length);
474
+ }
475
+
476
+ // Lazy-load the validator so an environment without `sources/validators`
477
+ // installed still gets the offline view.
478
+ let validator = null;
479
+ if (!offline) {
480
+ try {
481
+ validator = require('../sources/validators/rfc-validator.js');
482
+ } catch (err) {
483
+ console.log(`[validate-rfcs] note: validator module unavailable (${err.code || err.message}); falling back to offline mode.\n`);
484
+ }
485
+ }
486
+
487
+ let driftFound = 0;
488
+ let unreachable = 0;
489
+
490
+ // Cache-first helpers — read the prefetch payload for an RFC/draft and
491
+ // compute drift the same way validateRfc would. Cache misses fall through
492
+ // to the live validator.
493
+ const STATUS_MAP = {
494
+ std: 'Internet Standard', ps: 'Proposed Standard', ds: 'Draft Standard',
495
+ bcp: 'Best Current Practice', inf: 'Informational', exp: 'Experimental',
496
+ his: 'Historic', unkn: 'Unknown',
497
+ };
498
+ function rfcDocNameFor(id) {
499
+ if (id.startsWith('RFC-')) return `rfc${id.slice(4)}`;
500
+ if (id.startsWith('DRAFT-')) return `draft-${id.slice(6).toLowerCase()}`;
501
+ return null;
502
+ }
503
+ function readCachedRfc(docName) {
504
+ if (!cacheDir || !docName) return null;
505
+ const safe = docName.replace(/[^A-Za-z0-9._-]/g, '_');
506
+ const p = path.join(cacheDir, 'rfc', `${safe}.json`);
507
+ if (!fs.existsSync(p)) return null;
508
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
509
+ catch { return null; }
510
+ }
511
+ let cacheHits = 0;
512
+ let liveFallbacks = 0;
513
+
514
+ for (const id of ids) {
515
+ const entry = refs[id];
516
+ let liveStatus = offline || !validator ? 'skipped (offline)' : '?';
517
+ if (!offline) {
518
+ const cached = readCachedRfc(rfcDocNameFor(id));
519
+ if (cached) {
520
+ cacheHits++;
521
+ const obj = cached.objects?.[0];
522
+ if (!obj) {
523
+ liveStatus = 'NOT FOUND upstream (cache)';
524
+ driftFound++;
525
+ } else {
526
+ const upStatus = STATUS_MAP[obj.std_level] || null;
527
+ if (upStatus && entry.status && upStatus !== entry.status) {
528
+ liveStatus = `DRIFT: status local "${entry.status}" vs Datatracker "${upStatus}" (cache)`;
529
+ driftFound++;
530
+ } else {
531
+ liveStatus = 'match (cache)';
532
+ }
533
+ }
534
+ } else if (validator) {
535
+ liveFallbacks++;
536
+ try {
537
+ const result = await validator.validateRfc(id, entry);
538
+ if (result.status === 'unreachable') {
539
+ liveStatus = 'unreachable';
540
+ unreachable++;
541
+ } else if (result.status === 'match') {
542
+ liveStatus = 'match';
543
+ } else if (result.status === 'drift') {
544
+ liveStatus = 'DRIFT: ' + (result.discrepancies || []).join('; ');
545
+ driftFound++;
546
+ } else if (result.status === 'missing') {
547
+ liveStatus = 'NOT FOUND upstream';
548
+ driftFound++;
549
+ }
550
+ } catch (err) {
551
+ liveStatus = `error: ${err.message}`;
552
+ unreachable++;
553
+ }
554
+ }
555
+ }
556
+ console.log(
557
+ `${fmt(id, 32)}| ${fmt(entry.status, 20)} | ${fmt(entry.errata_count, 6)} | ${fmt(entry.last_verified, 13)} | ${liveStatus}`
558
+ );
559
+ }
560
+ if (cacheDir) {
561
+ console.log(`\n[validate-rfcs] cache hits: ${cacheHits}; live fallbacks: ${liveFallbacks}`);
562
+ }
563
+
564
+ console.log();
565
+ if (driftFound > 0) {
566
+ console.log(`[validate-rfcs] DRIFT DETECTED on ${driftFound} entry(ies). Update data/rfc-references.json and bump last_verified.`);
567
+ if (!noFail) process.exit(1);
568
+ } else if (unreachable > 0) {
569
+ console.log(`[validate-rfcs] ${unreachable} entry(ies) unreachable. Network/IETF Datatracker is intermittent — re-run later.`);
570
+ } else if (!offline && validator) {
571
+ console.log('[validate-rfcs] No drift detected against reachable upstream sources.');
572
+ } else {
573
+ console.log('[validate-rfcs] Offline view only. Re-run with --live (or omit --offline) to check against the IETF Datatracker.');
574
+ }
575
+ }
576
+
577
+ /**
578
+ * watchlist — aggregate `forward_watch` entries across every skill in
579
+ * manifest.json into a single deduplicated, sorted list, with the skills
580
+ * that listed each item and the most recent `last_threat_review` date among
581
+ * them. Supports `--by-skill` to invert the view (per-skill watch items).
582
+ *
583
+ * Per AGENTS.md, `forward_watch` is the optional frontmatter field every
584
+ * skill uses to flag upcoming standards changes, new TTPs, or RFC drops
585
+ * that should trigger a skill update. This command surfaces the union so
586
+ * maintainers can see the full horizon at a glance.
587
+ */
588
+ function runWatchlist(rawArgs = []) {
589
+ const fs = require('fs');
590
+ const path = require('path');
591
+ const { parseFrontmatter, extractFrontmatterBlock } = require('../lib/lint-skills.js');
592
+
593
+ const byskill = rawArgs.includes('--by-skill');
594
+ const manifestPath = path.join(__dirname, '..', 'manifest.json');
595
+ const repoRoot = path.join(__dirname, '..');
596
+
597
+ let manifest;
598
+ try {
599
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
600
+ } catch (err) {
601
+ console.error(`[watchlist] cannot read ${manifestPath}: ${err.message}`);
602
+ process.exit(2);
603
+ }
604
+
605
+ const skills = Array.isArray(manifest.skills) ? manifest.skills : [];
606
+ // item -> { skills: [{name, last_threat_review}] }
607
+ const itemToSkills = new Map();
608
+ // skill name -> { items: [...], last_threat_review }
609
+ const skillToItems = new Map();
610
+ let parseErrors = 0;
611
+
612
+ for (const entry of skills) {
613
+ const skillPath = path.join(repoRoot, entry.path);
614
+ if (!fs.existsSync(skillPath)) {
615
+ parseErrors++;
616
+ continue;
617
+ }
618
+ const content = fs.readFileSync(skillPath, 'utf8');
619
+ const { frontmatter: fmRaw } = extractFrontmatterBlock(content);
620
+ if (!fmRaw) {
621
+ parseErrors++;
622
+ continue;
623
+ }
624
+ let fm;
625
+ try {
626
+ fm = parseFrontmatter(fmRaw);
627
+ } catch {
628
+ parseErrors++;
629
+ continue;
630
+ }
631
+ const items = Array.isArray(fm.forward_watch) ? fm.forward_watch : [];
632
+ const reviewDate = typeof fm.last_threat_review === 'string' ? fm.last_threat_review : null;
633
+ skillToItems.set(entry.name, { items, last_threat_review: reviewDate });
634
+ for (const itemRaw of items) {
635
+ if (typeof itemRaw !== 'string' || !itemRaw.trim()) continue;
636
+ const item = itemRaw.trim();
637
+ if (!itemToSkills.has(item)) itemToSkills.set(item, []);
638
+ itemToSkills.get(item).push({ skill: entry.name, last_threat_review: reviewDate });
639
+ }
640
+ }
641
+
642
+ console.log(`\nForward-Watch Aggregator — ${new Date().toISOString()}`);
643
+ console.log(`Skills scanned: ${skills.length} parse errors: ${parseErrors}`);
644
+
645
+ if (byskill) {
646
+ console.log(`Mode: by-skill\n`);
647
+ const names = [...skillToItems.keys()].sort();
648
+ for (const name of names) {
649
+ const info = skillToItems.get(name);
650
+ console.log(`### ${name} (last_threat_review: ${info.last_threat_review || '-'})`);
651
+ if (info.items.length === 0) {
652
+ console.log(' (no forward_watch entries)');
653
+ } else {
654
+ for (const item of info.items) console.log(` - ${item}`);
655
+ }
656
+ console.log();
657
+ }
658
+ console.log(`Total unique watch items across all skills: ${itemToSkills.size}`);
659
+ return;
660
+ }
661
+
662
+ console.log(`Mode: aggregated (unique items across all skills)\n`);
663
+ const sortedItems = [...itemToSkills.keys()].sort((a, b) => a.localeCompare(b));
664
+ for (const item of sortedItems) {
665
+ const listers = itemToSkills.get(item);
666
+ const dates = listers.map(l => l.last_threat_review).filter(Boolean).sort();
667
+ const mostRecent = dates.length ? dates[dates.length - 1] : '-';
668
+ const skillNames = listers.map(l => l.skill).join(', ');
669
+ console.log(`- ${item}`);
670
+ console.log(` skills (${listers.length}): ${skillNames}`);
671
+ console.log(` most-recent last_threat_review among listers: ${mostRecent}`);
672
+ }
673
+
674
+ console.log(`\nTotal unique watch items: ${itemToSkills.size} (across ${skills.length} skills)`);
675
+ console.log(`Run with --by-skill to invert the view.`);
676
+ }
677
+
678
+ /**
679
+ * Cache-first variant of validateAllCves. For each catalog CVE, reads the
680
+ * NVD + EPSS payload from the prefetch cache (cacheDir/nvd/<id>.json +
681
+ * cacheDir/epss/<id>.json) and the KEV feed from cacheDir/kev/. Builds a
682
+ * ValidationResult matching the shape sources/validators/cve-validator.js
683
+ * produces so downstream consumers don't have to fork their logic.
684
+ *
685
+ * Missing cache entries fall through to the live validator for that CVE,
686
+ * so partial caches still produce a complete report.
687
+ */
688
+ async function validateAllCvesPreferCache(catalog, cacheDir) {
689
+ const fs = require('fs');
690
+ const path = require('path');
691
+ const { validateCve } = require('../sources/validators');
692
+
693
+ function readCached(source, id) {
694
+ const safe = id.replace(/[^A-Za-z0-9._-]/g, '_');
695
+ const p = path.join(cacheDir, source, `${safe}.json`);
696
+ if (!fs.existsSync(p)) return null;
697
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
698
+ catch { return null; }
699
+ }
700
+
701
+ function extractNvd(payload) {
702
+ const vuln = payload?.vulnerabilities?.[0]?.cve;
703
+ if (!vuln) return { found: false };
704
+ const m = vuln.metrics || {};
705
+ const ordered = [...(m.cvssMetricV31 || []), ...(m.cvssMetricV30 || []), ...(m.cvssMetricV2 || [])];
706
+ const primary = ordered.find((x) => x.type === 'Primary') || ordered[0];
707
+ return {
708
+ found: true,
709
+ score: typeof primary?.cvssData?.baseScore === 'number' ? primary.cvssData.baseScore : null,
710
+ vector: primary?.cvssData?.vectorString || null,
711
+ };
712
+ }
713
+
714
+ function extractEpss(payload, id) {
715
+ const data = Array.isArray(payload?.data) ? payload.data : [];
716
+ const row = data.find((r) => r?.cve === id) || data[0];
717
+ if (!row) return null;
718
+ return {
719
+ score: row.epss != null ? Number(row.epss) : null,
720
+ percentile: row.percentile != null ? Number(row.percentile) : null,
721
+ date: typeof row.date === 'string' ? row.date : null,
722
+ };
723
+ }
724
+
725
+ const kevFeed = readCached('kev', 'known_exploited_vulnerabilities');
726
+ const kevMap = new Map();
727
+ if (kevFeed) {
728
+ for (const v of kevFeed.vulnerabilities || []) {
729
+ if (v && v.cveID) kevMap.set(v.cveID, v);
730
+ }
731
+ }
732
+
733
+ const ids = Object.keys(catalog).filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k));
734
+ const results = [];
735
+ const by_status = { match: 0, drift: 0, unreachable: 0, missing: 0 };
736
+ let cacheHits = 0;
737
+ let liveFallbacks = 0;
738
+
739
+ for (const id of ids) {
740
+ const local = catalog[id];
741
+ const nvdPayload = readCached('nvd', id);
742
+ const epssPayload = readCached('epss', id);
743
+
744
+ if (!nvdPayload && !kevFeed && !epssPayload) {
745
+ // No cache for this CVE on any source — fall through to live.
746
+ liveFallbacks++;
747
+ try {
748
+ const r = await validateCve(id, local);
749
+ results.push(r);
750
+ by_status[r.status] = (by_status[r.status] || 0) + 1;
751
+ } catch (err) {
752
+ results.push({ cve_id: id, status: 'unreachable', discrepancies: [], fetched: { sources: { nvd: null, kev: null, epss: null } }, local, error: err.message });
753
+ by_status.unreachable++;
754
+ }
755
+ continue;
756
+ }
757
+
758
+ cacheHits++;
759
+ const discrepancies = [];
760
+ const fetched = {
761
+ cvss_score: null, cvss_vector: null,
762
+ in_kev: null, kev_date: null,
763
+ epss: null,
764
+ sources: { nvd: null, kev: null, epss: null },
765
+ };
766
+
767
+ if (nvdPayload) {
768
+ const n = extractNvd(nvdPayload);
769
+ if (n.found) {
770
+ fetched.cvss_score = n.score;
771
+ fetched.cvss_vector = n.vector;
772
+ fetched.sources.nvd = { reachable: true, found: true, fromCache: true };
773
+ if (n.score != null && local.cvss_score != null && Math.abs(n.score - local.cvss_score) > 0.05) {
774
+ discrepancies.push({ field: 'cvss_score', local: local.cvss_score, fetched: n.score, severity: 'high' });
775
+ }
776
+ if (n.vector && local.cvss_vector && n.vector !== local.cvss_vector) {
777
+ discrepancies.push({ field: 'cvss_vector', local: local.cvss_vector, fetched: n.vector, severity: 'medium' });
778
+ }
779
+ } else {
780
+ fetched.sources.nvd = { reachable: true, found: false, fromCache: true };
781
+ }
782
+ } else {
783
+ fetched.sources.nvd = { reachable: false, error: 'cache miss' };
784
+ }
785
+
786
+ if (kevFeed) {
787
+ const hit = kevMap.get(id);
788
+ fetched.in_kev = !!hit;
789
+ fetched.kev_date = hit?.dateAdded || null;
790
+ fetched.sources.kev = { reachable: true, total_entries: kevMap.size, fromCache: true };
791
+ if (typeof local.cisa_kev === 'boolean' && local.cisa_kev !== fetched.in_kev) {
792
+ discrepancies.push({ field: 'cisa_kev', local: local.cisa_kev, fetched: fetched.in_kev, severity: 'high' });
793
+ }
794
+ if (local.cisa_kev_date && fetched.kev_date && local.cisa_kev_date !== fetched.kev_date) {
795
+ discrepancies.push({ field: 'cisa_kev_date', local: local.cisa_kev_date, fetched: fetched.kev_date, severity: 'low' });
796
+ }
797
+ } else {
798
+ fetched.sources.kev = { reachable: false, error: 'cache miss' };
799
+ }
800
+
801
+ if (epssPayload) {
802
+ const e = extractEpss(epssPayload, id);
803
+ if (e) {
804
+ fetched.epss = e;
805
+ fetched.sources.epss = { reachable: true, found: true, date: e.date, fromCache: true };
806
+ if (e.score != null && local.epss_score != null && Math.abs(e.score - local.epss_score) > 0.05) {
807
+ discrepancies.push({ field: 'epss_score', local: local.epss_score, fetched: e.score, severity: 'medium' });
808
+ }
809
+ if (e.percentile != null && local.epss_percentile != null && Math.abs(e.percentile - local.epss_percentile) > 0.05) {
810
+ discrepancies.push({ field: 'epss_percentile', local: local.epss_percentile, fetched: e.percentile, severity: 'medium' });
811
+ }
812
+ } else {
813
+ fetched.sources.epss = { reachable: true, found: false, fromCache: true };
814
+ }
815
+ } else {
816
+ fetched.sources.epss = { reachable: false, error: 'cache miss' };
817
+ }
818
+
819
+ const status = discrepancies.length === 0 ? 'match' : 'drift';
820
+ results.push({ cve_id: id, status, discrepancies, fetched, local });
821
+ by_status[status] = (by_status[status] || 0) + 1;
822
+ }
823
+
824
+ return {
825
+ generated_at: new Date().toISOString(),
826
+ total: ids.length,
827
+ by_status,
828
+ drift_count: by_status.drift,
829
+ cache_hits: cacheHits,
830
+ live_fallbacks: liveFallbacks,
831
+ results,
832
+ };
833
+ }
834
+
835
+ function printHelp() {
836
+ console.log(`
837
+ exceptd Security Orchestrator
838
+
839
+ Commands:
840
+ scan Scan environment (kernel, MCP, crypto, AI APIs, framework gaps)
841
+ dispatch Scan then route findings to relevant skills
842
+ skill <name> Show context for a specific skill by name
843
+ pipeline [type] Initialize a pipeline run (type: new_cve|atlas_update|manual)
844
+ currency Check skill currency scores
845
+ report [format] Generate report (format: executive|technical|compliance)
846
+ watch Start event watcher (long-running)
847
+ validate-cves Cross-check the CVE catalog against NVD + CISA KEV + EPSS
848
+ Flags: --offline | --no-fail | --from-cache [<dir>]
849
+ --from-cache prefers cached upstream snapshots written by
850
+ \`npm run prefetch\` (default .cache/upstream); cache misses
851
+ fall back to live network per CVE.
852
+ validate-rfcs Cross-check the RFC catalog against IETF Datatracker
853
+ Flags: --offline | --no-fail | --from-cache [<dir>]
854
+ watchlist Aggregate forward_watch entries across all skills (--by-skill to invert)
855
+ help Show this help
856
+
857
+ Environment variables:
858
+ EXCEPTD_DATA_DIR Path to data directory (default: ../data)
859
+ EXCEPTD_MANIFEST Path to manifest.json (default: ../manifest.json)
860
+ EXCEPTD_SCAN_TARGETS Directories to scan for MCP configs
861
+
862
+ Examples:
863
+ node orchestrator/index.js scan
864
+ node orchestrator/index.js skill kernel-lpe-triage
865
+ node orchestrator/index.js currency
866
+ node orchestrator/index.js report executive
867
+ node orchestrator/index.js watch
868
+ `);
869
+ }
870
+
871
+ main().catch(err => {
872
+ console.error('[orchestrator] Fatal:', err.message);
873
+ process.exit(1);
874
+ });