@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
package/lib/sign.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Skill signing utility — Ed25519 keypair management and skill signing.
|
|
6
|
+
*
|
|
7
|
+
* The private key never enters this repository. It is stored at .keys/private.pem
|
|
8
|
+
* which is gitignored. The public key at keys/public.pem is tracked and used
|
|
9
|
+
* by lib/verify.js for signature verification.
|
|
10
|
+
*
|
|
11
|
+
* Signing ceremony:
|
|
12
|
+
* 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
|
|
13
|
+
* 2. node lib/sign.js sign-all — sign all skills (after any content change)
|
|
14
|
+
* 3. node lib/verify.js — verify all signatures
|
|
15
|
+
*
|
|
16
|
+
* Key rotation:
|
|
17
|
+
* 1. node lib/sign.js generate-keypair --rotate — generate new keypair, old sigs become invalid
|
|
18
|
+
* 2. node lib/sign.js sign-all — re-sign all skills with new key
|
|
19
|
+
* 3. Commit keys/public.pem update
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* node lib/sign.js generate-keypair [--rotate] — generate Ed25519 keypair
|
|
23
|
+
* node lib/sign.js sign-all — sign all skills in manifest
|
|
24
|
+
* node lib/sign.js sign <skill-name> — sign one skill
|
|
25
|
+
* node lib/sign.js show-pubkey — print the public key
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
|
|
32
|
+
const ROOT = path.join(__dirname, '..');
|
|
33
|
+
const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
|
|
34
|
+
const KEYS_DIR = path.join(ROOT, '.keys');
|
|
35
|
+
const PUBLIC_KEYS_DIR = path.join(ROOT, 'keys');
|
|
36
|
+
const PRIVATE_KEY_PATH = path.join(KEYS_DIR, 'private.pem');
|
|
37
|
+
const PUBLIC_KEY_PATH = path.join(PUBLIC_KEYS_DIR, 'public.pem');
|
|
38
|
+
|
|
39
|
+
// --- public API ---
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generate an Ed25519 keypair.
|
|
43
|
+
* Private key → .keys/private.pem (gitignored)
|
|
44
|
+
* Public key → keys/public.pem (tracked)
|
|
45
|
+
*
|
|
46
|
+
* @param {{ rotate: boolean }} options
|
|
47
|
+
*/
|
|
48
|
+
function generateKeypair({ rotate = false } = {}) {
|
|
49
|
+
if (fs.existsSync(PRIVATE_KEY_PATH) && !rotate) {
|
|
50
|
+
console.error('[sign] Private key already exists at .keys/private.pem');
|
|
51
|
+
console.error('[sign] Use --rotate to generate a new keypair and invalidate existing signatures.');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fs.mkdirSync(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
56
|
+
fs.mkdirSync(PUBLIC_KEYS_DIR, { recursive: true });
|
|
57
|
+
|
|
58
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
|
|
59
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
60
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
fs.writeFileSync(PRIVATE_KEY_PATH, privateKey, { encoding: 'utf8', mode: 0o600 });
|
|
64
|
+
fs.writeFileSync(PUBLIC_KEY_PATH, publicKey, { encoding: 'utf8', mode: 0o644 });
|
|
65
|
+
|
|
66
|
+
if (rotate) {
|
|
67
|
+
console.log('[sign] Keypair rotated. All existing signatures are now invalid — run: node lib/sign.js sign-all');
|
|
68
|
+
} else {
|
|
69
|
+
console.log('[sign] Ed25519 keypair generated.');
|
|
70
|
+
console.log(` Private key: .keys/private.pem (gitignored — do not commit)`);
|
|
71
|
+
console.log(` Public key: keys/public.pem (tracked — commit this)`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log('\nNext steps:');
|
|
75
|
+
console.log(' 1. node lib/sign.js sign-all — sign all current skills');
|
|
76
|
+
console.log(' 2. node lib/verify.js — confirm all signatures');
|
|
77
|
+
console.log(' 3. git add keys/public.pem && git commit -m "add signing public key"');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Sign all skills in manifest.json using the private key.
|
|
82
|
+
* Updates manifest.json with Ed25519 signatures.
|
|
83
|
+
*/
|
|
84
|
+
function signAll() {
|
|
85
|
+
const privateKey = loadPrivateKey();
|
|
86
|
+
const manifest = loadManifest();
|
|
87
|
+
let signed = 0;
|
|
88
|
+
let errors = 0;
|
|
89
|
+
|
|
90
|
+
for (const skill of manifest.skills) {
|
|
91
|
+
const skillPath = path.join(ROOT, skill.path);
|
|
92
|
+
if (!fs.existsSync(skillPath)) {
|
|
93
|
+
console.error(`[sign] SKIP ${skill.name}: file not found at ${skill.path}`);
|
|
94
|
+
errors++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const content = fs.readFileSync(skillPath, 'utf8');
|
|
98
|
+
skill.signature = signContent(content, privateKey);
|
|
99
|
+
skill.signed_at = new Date().toISOString();
|
|
100
|
+
delete skill.sha256;
|
|
101
|
+
console.log(`[sign] Signed: ${skill.name}`);
|
|
102
|
+
signed++;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
106
|
+
console.log(`\n[sign] ${signed} skills signed. ${errors} errors.`);
|
|
107
|
+
|
|
108
|
+
if (errors > 0) process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Sign a single skill by name.
|
|
113
|
+
* @param {string} skillName
|
|
114
|
+
*/
|
|
115
|
+
function signOne(skillName) {
|
|
116
|
+
const privateKey = loadPrivateKey();
|
|
117
|
+
const manifest = loadManifest();
|
|
118
|
+
const skill = manifest.skills.find(s => s.name === skillName);
|
|
119
|
+
if (!skill) { console.error(`Skill not found: ${skillName}`); process.exit(1); }
|
|
120
|
+
|
|
121
|
+
const skillPath = path.join(ROOT, skill.path);
|
|
122
|
+
const content = fs.readFileSync(skillPath, 'utf8');
|
|
123
|
+
skill.signature = signContent(content, privateKey);
|
|
124
|
+
skill.signed_at = new Date().toISOString();
|
|
125
|
+
delete skill.sha256;
|
|
126
|
+
|
|
127
|
+
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
128
|
+
console.log(`[sign] Signed: ${skillName}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- helpers ---
|
|
132
|
+
|
|
133
|
+
function signContent(content, privateKey) {
|
|
134
|
+
const signature = crypto.sign(null, Buffer.from(content, 'utf8'), {
|
|
135
|
+
key: privateKey,
|
|
136
|
+
dsaEncoding: 'ieee-p1363'
|
|
137
|
+
});
|
|
138
|
+
return signature.toString('base64');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadPrivateKey() {
|
|
142
|
+
if (!fs.existsSync(PRIVATE_KEY_PATH)) {
|
|
143
|
+
console.error('[sign] No private key at .keys/private.pem');
|
|
144
|
+
console.error('[sign] Run: node lib/sign.js generate-keypair');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
return fs.readFileSync(PRIVATE_KEY_PATH, 'utf8');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadManifest() {
|
|
151
|
+
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- CLI ---
|
|
155
|
+
|
|
156
|
+
if (require.main === module) {
|
|
157
|
+
const cmd = process.argv[2];
|
|
158
|
+
const arg = process.argv[3];
|
|
159
|
+
|
|
160
|
+
switch (cmd) {
|
|
161
|
+
case 'generate-keypair':
|
|
162
|
+
generateKeypair({ rotate: process.argv.includes('--rotate') });
|
|
163
|
+
break;
|
|
164
|
+
case 'sign-all':
|
|
165
|
+
signAll();
|
|
166
|
+
break;
|
|
167
|
+
case 'sign':
|
|
168
|
+
if (!arg) { console.error('Usage: node lib/sign.js sign <skill-name>'); process.exit(1); }
|
|
169
|
+
signOne(arg);
|
|
170
|
+
break;
|
|
171
|
+
case 'show-pubkey':
|
|
172
|
+
if (!fs.existsSync(PUBLIC_KEY_PATH)) {
|
|
173
|
+
console.error('[sign] No public key found. Run: node lib/sign.js generate-keypair');
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
process.stdout.write(fs.readFileSync(PUBLIC_KEY_PATH, 'utf8'));
|
|
177
|
+
break;
|
|
178
|
+
default:
|
|
179
|
+
console.log(`
|
|
180
|
+
exceptd Skill Signing Utility
|
|
181
|
+
|
|
182
|
+
Commands:
|
|
183
|
+
generate-keypair [--rotate] Generate Ed25519 keypair (.keys/ is gitignored)
|
|
184
|
+
sign-all Sign all skills in manifest.json
|
|
185
|
+
sign <skill-name> Sign one skill
|
|
186
|
+
show-pubkey Print the public key
|
|
187
|
+
|
|
188
|
+
Signing ceremony (first time):
|
|
189
|
+
1. node lib/sign.js generate-keypair
|
|
190
|
+
2. node lib/sign.js sign-all
|
|
191
|
+
3. node lib/verify.js
|
|
192
|
+
4. git add keys/public.pem
|
|
193
|
+
`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { generateKeypair, signAll, signOne };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TTP Mapper — maps compliance framework control IDs to ATLAS/ATT&CK TTPs
|
|
5
|
+
* and surfaces gaps where controls fail to cover attacker techniques.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function map(controlId, gapCatalog) {
|
|
9
|
+
const entry = gapCatalog[controlId];
|
|
10
|
+
if (!entry) return { control_id: controlId, found: false, message: 'Control not in gap catalog' };
|
|
11
|
+
return {
|
|
12
|
+
found: true,
|
|
13
|
+
control_id: controlId,
|
|
14
|
+
framework: entry.framework,
|
|
15
|
+
control_name: entry.control_name,
|
|
16
|
+
designed_for: entry.designed_for,
|
|
17
|
+
misses: entry.misses,
|
|
18
|
+
real_requirement: entry.real_requirement,
|
|
19
|
+
status: entry.status,
|
|
20
|
+
evidence_cves: entry.evidence_cves
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function gapsFor(attackPattern, gapCatalog, atlasCatalog) {
|
|
25
|
+
const results = [];
|
|
26
|
+
for (const [controlId, entry] of Object.entries(gapCatalog)) {
|
|
27
|
+
if (controlId.startsWith('_')) continue;
|
|
28
|
+
if (entry.misses && entry.misses.some(m => m.toLowerCase().includes(attackPattern.toLowerCase()))) {
|
|
29
|
+
results.push({ control_id: controlId, framework: entry.framework, control_name: entry.control_name, gap: entry.misses });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (results.length === 0) {
|
|
33
|
+
return { attack_pattern: attackPattern, found_gaps: false, message: 'No documented gaps for this pattern — verify manually' };
|
|
34
|
+
}
|
|
35
|
+
return { attack_pattern: attackPattern, found_gaps: true, controls_with_gap: results };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function coverage(frameworkId, ttpId, gapCatalog, atlasCatalog) {
|
|
39
|
+
const ttp = atlasCatalog[ttpId];
|
|
40
|
+
if (!ttp) return { ttp_id: ttpId, found: false };
|
|
41
|
+
|
|
42
|
+
// atlas-ttps.json uses controls_that_partially_help / controls_that_dont_help / framework_gap_detail
|
|
43
|
+
const partialControls = ttp.controls_that_partially_help || [];
|
|
44
|
+
const noHelpControls = ttp.controls_that_dont_help || [];
|
|
45
|
+
const gapDetail = ttp.framework_gap_detail || '';
|
|
46
|
+
const hasFrameworkGap = ttp.framework_gap === true;
|
|
47
|
+
|
|
48
|
+
// Check if the requested framework has any coverage in the partially-helpful controls
|
|
49
|
+
const frameworkPrefix = frameworkId.split('-')[0].toLowerCase();
|
|
50
|
+
const partial = partialControls.find(c => c.toLowerCase().includes(frameworkPrefix));
|
|
51
|
+
const noHelp = noHelpControls.find(c => c.toLowerCase().includes(frameworkPrefix));
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
ttp_id: ttpId,
|
|
55
|
+
ttp_name: ttp.name,
|
|
56
|
+
framework: frameworkId,
|
|
57
|
+
has_gap: hasFrameworkGap,
|
|
58
|
+
partially_covered_by: partial || null,
|
|
59
|
+
not_covered_by: noHelp || null,
|
|
60
|
+
gap_detail: gapDetail,
|
|
61
|
+
detection: ttp.detection || null
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function universalGaps() {
|
|
66
|
+
return [
|
|
67
|
+
{ gap: 'AI pipeline integrity', no_framework_coverage: true },
|
|
68
|
+
{ gap: 'MCP/agent tool trust boundaries', no_framework_coverage: true },
|
|
69
|
+
{ gap: 'LLM prompt injection as access control failure', no_framework_coverage: true },
|
|
70
|
+
{ gap: 'AI-as-C2 detection and response', no_framework_coverage: true },
|
|
71
|
+
{ gap: 'Live kernel patching as required capability', no_framework_coverage: true, closest: 'ASD ISM-1623 (48h)' },
|
|
72
|
+
{ gap: 'Ephemeral infrastructure asset inventory alternatives', no_framework_coverage: true },
|
|
73
|
+
{ gap: 'AI-accelerated exploit weaponization in patch SLAs', no_framework_coverage: true },
|
|
74
|
+
{ gap: 'RAG pipeline integrity and retrieval security', no_framework_coverage: true },
|
|
75
|
+
{ gap: 'AI-generated phishing detection update requirement', no_framework_coverage: true },
|
|
76
|
+
{ gap: 'Post-quantum cryptography migration mandate (non-NSS)', no_framework_coverage: true, closest: 'NSA CNSA 2.0 (NSS only)' }
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { map, gapsFor, coverage, universalGaps };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* lib/validate-catalog-meta.js — assert every data/*.json carries the
|
|
4
|
+
* source-trust + freshness fields required by the audit follow-up:
|
|
5
|
+
*
|
|
6
|
+
* _meta.tlp — Traffic Light Protocol marking
|
|
7
|
+
* _meta.source_confidence — Admiralty scheme (A-F + 1-6), with
|
|
8
|
+
* default rating and per-entry override note
|
|
9
|
+
* _meta.freshness_policy — review cadence + decay thresholds
|
|
10
|
+
*
|
|
11
|
+
* Per AGENTS.md rule #10 (no placeholder language), this validator
|
|
12
|
+
* rejects empty strings and the usual placeholder tokens. Rule #12
|
|
13
|
+
* (external data version pinning) is enforced informally — every catalog
|
|
14
|
+
* still needs its existing schema_version / last_updated fields, but
|
|
15
|
+
* those are validated by the existing per-catalog validators.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node lib/validate-catalog-meta.js
|
|
19
|
+
* node lib/validate-catalog-meta.js --quiet
|
|
20
|
+
*
|
|
21
|
+
* Exit code:
|
|
22
|
+
* 0 all catalogs have the required _meta fields
|
|
23
|
+
* 1 one or more catalogs missing a required field
|
|
24
|
+
* 2 argv error
|
|
25
|
+
*
|
|
26
|
+
* No external dependencies. Node 24 stdlib only.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
const process = require('node:process');
|
|
34
|
+
|
|
35
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
36
|
+
const DATA_DIR = path.join(REPO_ROOT, 'data');
|
|
37
|
+
|
|
38
|
+
const REQUIRED_TLP_VALUES = new Set([
|
|
39
|
+
'CLEAR',
|
|
40
|
+
'GREEN',
|
|
41
|
+
'AMBER',
|
|
42
|
+
'AMBER+STRICT',
|
|
43
|
+
'RED',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const PLACEHOLDER_TOKENS = [
|
|
47
|
+
/\btodo\b/i,
|
|
48
|
+
/\btbd\b/i,
|
|
49
|
+
/\bcoming soon\b/i,
|
|
50
|
+
/\bplaceholder\b/i,
|
|
51
|
+
/\bto be determined\b/i,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function parseArgs(argv) {
|
|
55
|
+
const opts = { quiet: false };
|
|
56
|
+
for (let i = 2; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i];
|
|
58
|
+
if (a === '--quiet' || a === '-q') opts.quiet = true;
|
|
59
|
+
else if (a === '--help' || a === '-h') {
|
|
60
|
+
console.log(
|
|
61
|
+
'Usage: node lib/validate-catalog-meta.js [--quiet]\n' +
|
|
62
|
+
'\n' +
|
|
63
|
+
' --quiet Suppress per-catalog PASS output; show failures only.\n',
|
|
64
|
+
);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
} else {
|
|
67
|
+
console.error(`Unknown argument: ${a}`);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return opts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readJson(p) {
|
|
75
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function containsPlaceholder(s) {
|
|
79
|
+
if (typeof s !== 'string') return false;
|
|
80
|
+
return PLACEHOLDER_TOKENS.some((re) => re.test(s));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateMeta(catalogPath) {
|
|
84
|
+
const errors = [];
|
|
85
|
+
const data = readJson(catalogPath);
|
|
86
|
+
const meta = data._meta;
|
|
87
|
+
|
|
88
|
+
if (!meta || typeof meta !== 'object') {
|
|
89
|
+
return ['missing _meta block'];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* tlp */
|
|
93
|
+
if (typeof meta.tlp !== 'string') {
|
|
94
|
+
errors.push('_meta.tlp is missing or not a string');
|
|
95
|
+
} else if (!REQUIRED_TLP_VALUES.has(meta.tlp)) {
|
|
96
|
+
errors.push(
|
|
97
|
+
`_meta.tlp "${meta.tlp}" not one of CLEAR/GREEN/AMBER/AMBER+STRICT/RED`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* source_confidence */
|
|
102
|
+
const sc = meta.source_confidence;
|
|
103
|
+
if (!sc || typeof sc !== 'object') {
|
|
104
|
+
errors.push('_meta.source_confidence is missing or not an object');
|
|
105
|
+
} else {
|
|
106
|
+
for (const field of ['scheme', 'default', 'note']) {
|
|
107
|
+
if (typeof sc[field] !== 'string' || sc[field].length === 0) {
|
|
108
|
+
errors.push(`_meta.source_confidence.${field} missing or empty`);
|
|
109
|
+
} else if (containsPlaceholder(sc[field])) {
|
|
110
|
+
errors.push(
|
|
111
|
+
`_meta.source_confidence.${field} contains placeholder language`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (typeof sc.default === 'string' && !/^[A-F][1-6]$/.test(sc.default)) {
|
|
116
|
+
errors.push(
|
|
117
|
+
`_meta.source_confidence.default "${sc.default}" is not Admiralty form ([A-F][1-6])`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* freshness_policy */
|
|
123
|
+
const fp = meta.freshness_policy;
|
|
124
|
+
if (!fp || typeof fp !== 'object') {
|
|
125
|
+
errors.push('_meta.freshness_policy is missing or not an object');
|
|
126
|
+
} else {
|
|
127
|
+
for (const field of [
|
|
128
|
+
'default_review_cadence_days',
|
|
129
|
+
'stale_after_days',
|
|
130
|
+
'rebuild_after_days',
|
|
131
|
+
]) {
|
|
132
|
+
const v = fp[field];
|
|
133
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v <= 0) {
|
|
134
|
+
errors.push(
|
|
135
|
+
`_meta.freshness_policy.${field} must be a positive integer`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (typeof fp.note !== 'string' || fp.note.length === 0) {
|
|
140
|
+
errors.push('_meta.freshness_policy.note missing or empty');
|
|
141
|
+
} else if (containsPlaceholder(fp.note)) {
|
|
142
|
+
errors.push('_meta.freshness_policy.note contains placeholder language');
|
|
143
|
+
}
|
|
144
|
+
/* Soft check: cadence < stale < rebuild. Catches an obvious copy-paste
|
|
145
|
+
* mistake without being a hard schema constraint. */
|
|
146
|
+
if (
|
|
147
|
+
typeof fp.default_review_cadence_days === 'number' &&
|
|
148
|
+
typeof fp.stale_after_days === 'number' &&
|
|
149
|
+
typeof fp.rebuild_after_days === 'number'
|
|
150
|
+
) {
|
|
151
|
+
if (
|
|
152
|
+
!(
|
|
153
|
+
fp.default_review_cadence_days <= fp.stale_after_days &&
|
|
154
|
+
fp.stale_after_days <= fp.rebuild_after_days
|
|
155
|
+
)
|
|
156
|
+
) {
|
|
157
|
+
errors.push(
|
|
158
|
+
'_meta.freshness_policy: expected default_review_cadence_days <= stale_after_days <= rebuild_after_days',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return errors;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function main() {
|
|
168
|
+
const opts = parseArgs(process.argv);
|
|
169
|
+
const files = fs
|
|
170
|
+
.readdirSync(DATA_DIR)
|
|
171
|
+
.filter((f) => f.endsWith('.json'))
|
|
172
|
+
.sort();
|
|
173
|
+
|
|
174
|
+
let failed = 0;
|
|
175
|
+
for (const f of files) {
|
|
176
|
+
const errors = validateMeta(path.join(DATA_DIR, f));
|
|
177
|
+
if (errors.length === 0) {
|
|
178
|
+
if (!opts.quiet) console.log(`PASS ${f}`);
|
|
179
|
+
} else {
|
|
180
|
+
failed++;
|
|
181
|
+
console.log(`FAIL ${f}`);
|
|
182
|
+
for (const e of errors) console.log(` - ${e}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const total = files.length;
|
|
187
|
+
const passed = total - failed;
|
|
188
|
+
console.log(
|
|
189
|
+
`\n${passed}/${total} catalogs validated${failed ? `, ${failed} failed` : ''}.`,
|
|
190
|
+
);
|
|
191
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (require.main === module) {
|
|
195
|
+
main();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { validateMeta };
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* lib/validate-cve-catalog.js — exceptd CVE catalog validator.
|
|
4
|
+
*
|
|
5
|
+
* Enforces AGENTS.md rule #6 (zero-day learning is live): every CVE in
|
|
6
|
+
* data/cve-catalog.json must have a matching entry in data/zeroday-lessons.json
|
|
7
|
+
* by the same CVE key. The learning loop runs completely, not partially.
|
|
8
|
+
*
|
|
9
|
+
* Also enforces rule #10 (no placeholder data) by validating each CVE entry
|
|
10
|
+
* against the structural rules expressed in
|
|
11
|
+
* lib/schemas/cve-catalog.schema.json — required fields, types, enums,
|
|
12
|
+
* patterns, range constraints. Implemented as an inline validator (no AJV /
|
|
13
|
+
* external deps; Node 24 stdlib only) covering the schema features that
|
|
14
|
+
* file actually uses.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node lib/validate-cve-catalog.js validate the full catalog
|
|
18
|
+
* node lib/validate-cve-catalog.js --quiet only print failures + summary
|
|
19
|
+
*
|
|
20
|
+
* Exit code: 0 if every CVE validates and has a matching zeroday lesson,
|
|
21
|
+
* 1 otherwise, 2 on argv error.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const process = require('node:process');
|
|
29
|
+
|
|
30
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
31
|
+
const SCHEMA_PATH = path.join(REPO_ROOT, 'lib', 'schemas', 'cve-catalog.schema.json');
|
|
32
|
+
const CATALOG_PATH = path.join(REPO_ROOT, 'data', 'cve-catalog.json');
|
|
33
|
+
const LESSONS_PATH = path.join(REPO_ROOT, 'data', 'zeroday-lessons.json');
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const opts = { quiet: false };
|
|
37
|
+
for (let i = 2; i < argv.length; i++) {
|
|
38
|
+
const a = argv[i];
|
|
39
|
+
if (a === '--quiet' || a === '-q') opts.quiet = true;
|
|
40
|
+
else if (a === '--help' || a === '-h') {
|
|
41
|
+
console.log(
|
|
42
|
+
'Usage: node lib/validate-cve-catalog.js [--quiet]\n' +
|
|
43
|
+
'\n' +
|
|
44
|
+
' --quiet Suppress per-CVE PASS output; show failures only.\n',
|
|
45
|
+
);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
} else {
|
|
48
|
+
console.error(`Unknown argument: ${a}`);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return opts;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readJson(p) {
|
|
56
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function typeOf(value) {
|
|
60
|
+
if (value === null) return 'null';
|
|
61
|
+
if (Array.isArray(value)) return 'array';
|
|
62
|
+
return typeof value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function typeMatches(value, expected) {
|
|
66
|
+
if (Array.isArray(expected)) return expected.some((t) => typeMatches(value, t));
|
|
67
|
+
const actual = typeOf(value);
|
|
68
|
+
if (expected === 'integer') return actual === 'number' && Number.isInteger(value);
|
|
69
|
+
return actual === expected;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Validate a value against a (subset of) JSON Schema. The subset we need is
|
|
73
|
+
* what lib/schemas/cve-catalog.schema.json uses: type, required, properties,
|
|
74
|
+
* additionalProperties, items, pattern, minLength, minimum, maximum, minItems,
|
|
75
|
+
* minProperties, enum, format=uri (loose check). */
|
|
76
|
+
function validate(value, schema, schemaName, pathStr) {
|
|
77
|
+
const errors = [];
|
|
78
|
+
const here = pathStr || schemaName;
|
|
79
|
+
|
|
80
|
+
if (schema.type !== undefined) {
|
|
81
|
+
if (!typeMatches(value, schema.type)) {
|
|
82
|
+
errors.push(
|
|
83
|
+
`${here}: expected type ${JSON.stringify(schema.type)}, got ${typeOf(value)}`,
|
|
84
|
+
);
|
|
85
|
+
return errors;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (schema.enum !== undefined) {
|
|
90
|
+
if (!schema.enum.includes(value)) {
|
|
91
|
+
errors.push(`${here}: value ${JSON.stringify(value)} not in enum ${JSON.stringify(schema.enum)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const t = typeOf(value);
|
|
96
|
+
|
|
97
|
+
if (t === 'string') {
|
|
98
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
99
|
+
errors.push(`${here}: string shorter than minLength ${schema.minLength}`);
|
|
100
|
+
}
|
|
101
|
+
if (schema.pattern !== undefined) {
|
|
102
|
+
const re = new RegExp(schema.pattern);
|
|
103
|
+
if (!re.test(value)) {
|
|
104
|
+
errors.push(`${here}: string ${JSON.stringify(value)} does not match pattern /${schema.pattern}/`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (schema.format === 'uri') {
|
|
108
|
+
try {
|
|
109
|
+
new URL(value);
|
|
110
|
+
} catch {
|
|
111
|
+
errors.push(`${here}: value ${JSON.stringify(value)} is not a valid URI`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (t === 'number') {
|
|
117
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
118
|
+
errors.push(`${here}: value ${value} < minimum ${schema.minimum}`);
|
|
119
|
+
}
|
|
120
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
121
|
+
errors.push(`${here}: value ${value} > maximum ${schema.maximum}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (t === 'array') {
|
|
126
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
127
|
+
errors.push(`${here}: array shorter than minItems ${schema.minItems}`);
|
|
128
|
+
}
|
|
129
|
+
if (schema.items !== undefined) {
|
|
130
|
+
value.forEach((item, idx) => {
|
|
131
|
+
errors.push(...validate(item, schema.items, schemaName, `${here}[${idx}]`));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (t === 'object') {
|
|
137
|
+
if (schema.required) {
|
|
138
|
+
for (const req of schema.required) {
|
|
139
|
+
if (!(req in value)) {
|
|
140
|
+
errors.push(`${here}: missing required field "${req}"`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (schema.minProperties !== undefined && Object.keys(value).length < schema.minProperties) {
|
|
145
|
+
errors.push(`${here}: object has fewer than ${schema.minProperties} properties`);
|
|
146
|
+
}
|
|
147
|
+
const props = schema.properties || {};
|
|
148
|
+
const allowAdditional = schema.additionalProperties !== false;
|
|
149
|
+
const addlSchema =
|
|
150
|
+
typeof schema.additionalProperties === 'object' ? schema.additionalProperties : null;
|
|
151
|
+
for (const [k, v] of Object.entries(value)) {
|
|
152
|
+
if (k in props) {
|
|
153
|
+
errors.push(...validate(v, props[k], schemaName, `${here}.${k}`));
|
|
154
|
+
} else if (addlSchema) {
|
|
155
|
+
errors.push(...validate(v, addlSchema, schemaName, `${here}.${k}`));
|
|
156
|
+
} else if (!allowAdditional) {
|
|
157
|
+
errors.push(`${here}: unexpected property "${k}"`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return errors;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function main() {
|
|
166
|
+
const opts = parseArgs(process.argv);
|
|
167
|
+
const schema = readJson(SCHEMA_PATH);
|
|
168
|
+
const catalog = readJson(CATALOG_PATH);
|
|
169
|
+
const lessons = readJson(LESSONS_PATH);
|
|
170
|
+
|
|
171
|
+
const cveKeys = Object.keys(catalog).filter((k) => !k.startsWith('_'));
|
|
172
|
+
const lessonKeys = new Set(Object.keys(lessons).filter((k) => !k.startsWith('_')));
|
|
173
|
+
|
|
174
|
+
let failed = 0;
|
|
175
|
+
for (const key of cveKeys) {
|
|
176
|
+
const entry = catalog[key];
|
|
177
|
+
const errors = validate(entry, schema, 'cve', key);
|
|
178
|
+
if (!lessonKeys.has(key)) {
|
|
179
|
+
errors.push(
|
|
180
|
+
`${key}: missing matching entry in data/zeroday-lessons.json (rule #6: zero-day learning is live)`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (errors.length === 0) {
|
|
184
|
+
if (!opts.quiet) console.log(`PASS ${key}`);
|
|
185
|
+
} else {
|
|
186
|
+
failed++;
|
|
187
|
+
console.log(`FAIL ${key}`);
|
|
188
|
+
for (const e of errors) console.log(` - ${e}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* Reverse check: every zeroday lesson should map to a real CVE. Not strictly
|
|
193
|
+
* required by AGENTS.md but a stale lesson with no catalog entry is exactly
|
|
194
|
+
* the placeholder propagation rule #6 wants to catch. */
|
|
195
|
+
for (const lessonKey of lessonKeys) {
|
|
196
|
+
if (!cveKeys.includes(lessonKey)) {
|
|
197
|
+
failed++;
|
|
198
|
+
console.log(`FAIL ${lessonKey}`);
|
|
199
|
+
console.log(
|
|
200
|
+
` - zeroday-lessons.json entry has no matching CVE in cve-catalog.json`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const total = cveKeys.length;
|
|
206
|
+
const passed = total - failed;
|
|
207
|
+
console.log(
|
|
208
|
+
`\n${passed}/${total} CVE entries validated${failed ? `, ${failed} failed` : ''}.`,
|
|
209
|
+
);
|
|
210
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
main();
|