@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
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();