@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.
- package/AGENTS.md +232 -0
- package/ARCHITECTURE.md +267 -0
- package/CHANGELOG.md +616 -0
- package/CONTEXT.md +203 -0
- package/LICENSE +200 -0
- package/NOTICE +82 -0
- package/README.md +307 -0
- package/SECURITY.md +73 -0
- package/agents/README.md +81 -0
- package/agents/report-generator.md +156 -0
- package/agents/skill-updater.md +102 -0
- package/agents/source-validator.md +119 -0
- package/agents/threat-researcher.md +149 -0
- package/bin/exceptd.js +183 -0
- package/data/_indexes/_meta.json +88 -0
- package/data/_indexes/activity-feed.json +362 -0
- package/data/_indexes/catalog-summaries.json +229 -0
- package/data/_indexes/chains.json +7135 -0
- package/data/_indexes/currency.json +359 -0
- package/data/_indexes/did-ladders.json +451 -0
- package/data/_indexes/frequency.json +2072 -0
- package/data/_indexes/handoff-dag.json +476 -0
- package/data/_indexes/jurisdiction-clocks.json +967 -0
- package/data/_indexes/jurisdiction-map.json +536 -0
- package/data/_indexes/recipes.json +319 -0
- package/data/_indexes/section-offsets.json +3656 -0
- package/data/_indexes/stale-content.json +14 -0
- package/data/_indexes/summary-cards.json +1736 -0
- package/data/_indexes/theater-fingerprints.json +381 -0
- package/data/_indexes/token-budget.json +2137 -0
- package/data/_indexes/trigger-table.json +1374 -0
- package/data/_indexes/xref.json +818 -0
- package/data/atlas-ttps.json +282 -0
- package/data/cve-catalog.json +496 -0
- package/data/cwe-catalog.json +1017 -0
- package/data/d3fend-catalog.json +738 -0
- package/data/dlp-controls.json +1039 -0
- package/data/exploit-availability.json +67 -0
- package/data/framework-control-gaps.json +1255 -0
- package/data/global-frameworks.json +2913 -0
- package/data/rfc-references.json +324 -0
- package/data/zeroday-lessons.json +377 -0
- package/keys/public.pem +3 -0
- package/lib/framework-gap.js +328 -0
- package/lib/job-queue.js +195 -0
- package/lib/lint-skills.js +536 -0
- package/lib/prefetch.js +372 -0
- package/lib/refresh-external.js +713 -0
- package/lib/schemas/cve-catalog.schema.json +151 -0
- package/lib/schemas/manifest.schema.json +106 -0
- package/lib/schemas/skill-frontmatter.schema.json +113 -0
- package/lib/scoring.js +149 -0
- package/lib/sign.js +197 -0
- package/lib/ttp-mapper.js +80 -0
- package/lib/validate-catalog-meta.js +198 -0
- package/lib/validate-cve-catalog.js +213 -0
- package/lib/validate-indexes.js +83 -0
- package/lib/validate-package.js +162 -0
- package/lib/validate-vendor.js +85 -0
- package/lib/verify.js +216 -0
- package/lib/worker-pool.js +84 -0
- package/manifest-snapshot.json +1833 -0
- package/manifest.json +2108 -0
- package/orchestrator/README.md +124 -0
- package/orchestrator/dispatcher.js +140 -0
- package/orchestrator/event-bus.js +146 -0
- package/orchestrator/index.js +874 -0
- package/orchestrator/pipeline.js +201 -0
- package/orchestrator/scanner.js +327 -0
- package/orchestrator/scheduler.js +137 -0
- package/package.json +113 -0
- package/sbom.cdx.json +158 -0
- package/scripts/audit-cross-skill.js +261 -0
- package/scripts/audit-perf.js +160 -0
- package/scripts/bootstrap.js +205 -0
- package/scripts/build-indexes.js +721 -0
- package/scripts/builders/activity-feed.js +79 -0
- package/scripts/builders/catalog-summaries.js +67 -0
- package/scripts/builders/currency.js +109 -0
- package/scripts/builders/cwe-chains.js +105 -0
- package/scripts/builders/did-ladders.js +149 -0
- package/scripts/builders/frequency.js +89 -0
- package/scripts/builders/jurisdiction-clocks.js +126 -0
- package/scripts/builders/recipes.js +159 -0
- package/scripts/builders/section-offsets.js +162 -0
- package/scripts/builders/stale-content.js +171 -0
- package/scripts/builders/summary-cards.js +166 -0
- package/scripts/builders/theater-fingerprints.js +198 -0
- package/scripts/builders/token-budget.js +96 -0
- package/scripts/check-manifest-snapshot.js +217 -0
- package/scripts/predeploy.js +267 -0
- package/scripts/refresh-manifest-snapshot.js +57 -0
- package/scripts/refresh-sbom.js +222 -0
- package/skills/age-gates-child-safety/skill.md +456 -0
- package/skills/ai-attack-surface/skill.md +282 -0
- package/skills/ai-c2-detection/skill.md +440 -0
- package/skills/ai-risk-management/skill.md +311 -0
- package/skills/api-security/skill.md +287 -0
- package/skills/attack-surface-pentest/skill.md +381 -0
- package/skills/cloud-security/skill.md +384 -0
- package/skills/compliance-theater/skill.md +365 -0
- package/skills/container-runtime-security/skill.md +379 -0
- package/skills/coordinated-vuln-disclosure/skill.md +473 -0
- package/skills/defensive-countermeasure-mapping/skill.md +300 -0
- package/skills/dlp-gap-analysis/skill.md +337 -0
- package/skills/email-security-anti-phishing/skill.md +206 -0
- package/skills/exploit-scoring/skill.md +331 -0
- package/skills/framework-gap-analysis/skill.md +374 -0
- package/skills/fuzz-testing-strategy/skill.md +313 -0
- package/skills/global-grc/skill.md +564 -0
- package/skills/identity-assurance/skill.md +272 -0
- package/skills/incident-response-playbook/skill.md +546 -0
- package/skills/kernel-lpe-triage/skill.md +303 -0
- package/skills/mcp-agent-trust/skill.md +326 -0
- package/skills/mlops-security/skill.md +325 -0
- package/skills/ot-ics-security/skill.md +340 -0
- package/skills/policy-exception-gen/skill.md +437 -0
- package/skills/pqc-first/skill.md +546 -0
- package/skills/rag-pipeline-security/skill.md +294 -0
- package/skills/researcher/skill.md +310 -0
- package/skills/sector-energy/skill.md +409 -0
- package/skills/sector-federal-government/skill.md +302 -0
- package/skills/sector-financial/skill.md +398 -0
- package/skills/sector-healthcare/skill.md +373 -0
- package/skills/security-maturity-tiers/skill.md +464 -0
- package/skills/skill-update-loop/skill.md +463 -0
- package/skills/supply-chain-integrity/skill.md +318 -0
- package/skills/threat-model-currency/skill.md +404 -0
- package/skills/threat-modeling-methodology/skill.md +312 -0
- package/skills/webapp-security/skill.md +281 -0
- package/skills/zeroday-gap-learn/skill.md +350 -0
- package/vendor/blamejs/LICENSE +201 -0
- package/vendor/blamejs/README.md +54 -0
- package/vendor/blamejs/_PROVENANCE.json +54 -0
- package/vendor/blamejs/retry.js +335 -0
- 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
|
+
});
|