@blamejs/exceptd-skills 0.12.11 → 0.12.13
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/CHANGELOG.md +93 -0
- package/bin/exceptd.js +152 -39
- package/data/_indexes/_meta.json +7 -6
- package/data/_indexes/activity-feed.json +10 -2
- package/data/_indexes/catalog-summaries.json +23 -1
- package/data/attack-techniques.json +96 -0
- package/lib/cve-curation.js +491 -46
- package/lib/lint-skills.js +212 -15
- package/lib/playbook-runner.js +485 -108
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +221 -73
- package/lib/refresh-network.js +15 -1
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +68 -5
- package/lib/sign.js +112 -3
- package/lib/validate-cve-catalog.js +171 -3
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +241 -16
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/scheduler.js +50 -7
- package/package.json +1 -1
- package/sbom.cdx.json +8 -8
- package/scripts/predeploy.js +31 -5
package/lib/scoring.js
CHANGED
|
@@ -34,7 +34,62 @@ function score(cveId, catalog) {
|
|
|
34
34
|
return entry.rwep_score;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* E10: Validate an RWEP factor bag. Returns an array of warning strings
|
|
39
|
+
* for missing-but-defaultable fields and out-of-range values. Does NOT
|
|
40
|
+
* throw — operators wanting hard enforcement should treat a non-empty
|
|
41
|
+
* return as a failure themselves.
|
|
42
|
+
*
|
|
43
|
+
* Range expectations:
|
|
44
|
+
* - cisa_kev, poc_available, ai_assisted_weapon, ai_discovered,
|
|
45
|
+
* patch_available, live_patch_available, reboot_required: boolean
|
|
46
|
+
* (or null, treated as false with a missing-field warning).
|
|
47
|
+
* - active_exploitation: 'none' | 'unknown' | 'suspected' | 'confirmed'.
|
|
48
|
+
* - blast_radius: integer in [0, 30] (clamped at the weight ceiling but
|
|
49
|
+
* flagged when out-of-range — out-of-range usually means a unit error).
|
|
50
|
+
*/
|
|
51
|
+
function validateFactors(factors) {
|
|
52
|
+
const warnings = [];
|
|
53
|
+
if (!factors || typeof factors !== 'object') {
|
|
54
|
+
return ['factors: expected object, got ' + (factors === null ? 'null' : typeof factors)];
|
|
55
|
+
}
|
|
56
|
+
const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
57
|
+
'patch_available', 'live_patch_available', 'reboot_required'];
|
|
58
|
+
for (const f of boolFields) {
|
|
59
|
+
if (factors[f] === undefined || factors[f] === null) {
|
|
60
|
+
warnings.push(`${f}: missing (treated as false; explicit value recommended)`);
|
|
61
|
+
} else if (typeof factors[f] !== 'boolean') {
|
|
62
|
+
warnings.push(`${f}: expected boolean, got ${typeof factors[f]} (${JSON.stringify(factors[f])})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const aeAllowed = ['none', 'unknown', 'suspected', 'confirmed'];
|
|
66
|
+
if (factors.active_exploitation === undefined || factors.active_exploitation === null) {
|
|
67
|
+
warnings.push("active_exploitation: missing (treated as 'none')");
|
|
68
|
+
} else if (!aeAllowed.includes(factors.active_exploitation)) {
|
|
69
|
+
warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
|
|
70
|
+
}
|
|
71
|
+
if (factors.blast_radius === undefined || factors.blast_radius === null) {
|
|
72
|
+
warnings.push('blast_radius: missing (treated as 0)');
|
|
73
|
+
} else if (typeof factors.blast_radius !== 'number' || Number.isNaN(factors.blast_radius)) {
|
|
74
|
+
warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
|
|
75
|
+
} else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
|
|
76
|
+
warnings.push(`blast_radius: ${factors.blast_radius} out of expected range [0, 30] (clamped to weight ceiling, but the value usually indicates a unit-of-measure mistake)`);
|
|
77
|
+
}
|
|
78
|
+
return warnings;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* scoreCustom — compute the RWEP for a factor bag. Returns a number
|
|
83
|
+
* (clamped to [0, 100]).
|
|
84
|
+
*
|
|
85
|
+
* Backward-compat note: this function has always returned a number;
|
|
86
|
+
* callers in lib/auto-discovery.js etc. rely on that. E10 surfaces
|
|
87
|
+
* warnings via the optional `opts.collectWarnings` flag — when true,
|
|
88
|
+
* scoreCustom returns `{ score, _scoring_warnings }` instead of a bare
|
|
89
|
+
* number. Operators wanting validation without the score can call
|
|
90
|
+
* `validateFactors(factors)` directly.
|
|
91
|
+
*/
|
|
92
|
+
function scoreCustom(factors, opts) {
|
|
38
93
|
const {
|
|
39
94
|
cisa_kev = false,
|
|
40
95
|
poc_available = false,
|
|
@@ -45,7 +100,7 @@ function scoreCustom(factors) {
|
|
|
45
100
|
patch_available = false,
|
|
46
101
|
live_patch_available = false,
|
|
47
102
|
reboot_required = false
|
|
48
|
-
} = factors;
|
|
103
|
+
} = factors || {};
|
|
49
104
|
|
|
50
105
|
let score = 0;
|
|
51
106
|
score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
|
|
@@ -53,12 +108,20 @@ function scoreCustom(factors) {
|
|
|
53
108
|
score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
|
|
54
109
|
score += active_exploitation === 'confirmed' ? RWEP_WEIGHTS.active_exploitation : 0;
|
|
55
110
|
score += active_exploitation === 'suspected' ? Math.floor(RWEP_WEIGHTS.active_exploitation / 2) : 0;
|
|
56
|
-
|
|
111
|
+
// Clamp blast_radius into the weight-ceiling band [0, RWEP_WEIGHTS.blast_radius]
|
|
112
|
+
// before adding. Out-of-range values still produce a clamped contribution but
|
|
113
|
+
// validateFactors() surfaces the anomaly so operators see the unit error.
|
|
114
|
+
const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, typeof blast_radius === 'number' ? blast_radius : 0));
|
|
115
|
+
score += brClamped;
|
|
57
116
|
score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
|
|
58
117
|
score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
|
|
59
118
|
score += reboot_required ? RWEP_WEIGHTS.reboot_required : 0;
|
|
60
119
|
|
|
61
|
-
|
|
120
|
+
const clamped = Math.min(100, Math.max(0, score));
|
|
121
|
+
if (opts && opts.collectWarnings) {
|
|
122
|
+
return { score: clamped, _scoring_warnings: validateFactors(factors) };
|
|
123
|
+
}
|
|
124
|
+
return clamped;
|
|
62
125
|
}
|
|
63
126
|
|
|
64
127
|
function timeline(rwepScore) {
|
|
@@ -146,4 +209,4 @@ function validate(catalog) {
|
|
|
146
209
|
return errors;
|
|
147
210
|
}
|
|
148
211
|
|
|
149
|
-
module.exports = { score, scoreCustom, timeline, compare, validate, RWEP_WEIGHTS };
|
|
212
|
+
module.exports = { score, scoreCustom, timeline, compare, validate, validateFactors, RWEP_WEIGHTS };
|
package/lib/sign.js
CHANGED
|
@@ -8,6 +8,22 @@
|
|
|
8
8
|
* which is gitignored. The public key at keys/public.pem is tracked and used
|
|
9
9
|
* by lib/verify.js for signature verification.
|
|
10
10
|
*
|
|
11
|
+
* Byte-stability contract (must mirror lib/verify.js):
|
|
12
|
+
* Skill content is normalized BEFORE the bytes are signed:
|
|
13
|
+
* 1. Strip a UTF-8 BOM (U+FEFF) if present.
|
|
14
|
+
* 2. Convert CRLF line endings to LF.
|
|
15
|
+
* The same normalization runs in lib/verify.js. A skill file checked
|
|
16
|
+
* out with core.autocrlf=true on Windows therefore signs to the SAME
|
|
17
|
+
* signature as the LF copy on Linux CI — closing the regression class
|
|
18
|
+
* that broke v0.11.x signatures across the Windows/CI line-ending
|
|
19
|
+
* boundary. ANY change to normalize() requires the matching change in
|
|
20
|
+
* lib/verify.js; round-trip stability is a hard contract.
|
|
21
|
+
*
|
|
22
|
+
* Manifest entries are also validated before iteration: skill.path must
|
|
23
|
+
* begin with "skills/" and must not contain ".." or backslashes (see
|
|
24
|
+
* validateSkillPath() below). Without this a tampered manifest could
|
|
25
|
+
* sign or verify arbitrary files outside the skills/ tree.
|
|
26
|
+
*
|
|
11
27
|
* Signing ceremony:
|
|
12
28
|
* 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
|
|
13
29
|
* 2. node lib/sign.js sign-all — sign all skills (after any content change)
|
|
@@ -80,10 +96,20 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
80
96
|
/**
|
|
81
97
|
* Sign all skills in manifest.json using the private key.
|
|
82
98
|
* Updates manifest.json with Ed25519 signatures.
|
|
99
|
+
*
|
|
100
|
+
* Each manifest entry's `path` is validated through validateSkillPath()
|
|
101
|
+
* BEFORE the file is read — a tampered manifest with an out-of-tree
|
|
102
|
+
* path will reject the whole run.
|
|
83
103
|
*/
|
|
84
104
|
function signAll() {
|
|
85
105
|
const privateKey = loadPrivateKey();
|
|
86
106
|
const manifest = loadManifest();
|
|
107
|
+
// Validate every entry's path before doing any I/O. Reject the whole
|
|
108
|
+
// manifest on the first traversal attempt — we never want to sign
|
|
109
|
+
// half a manifest then exit non-zero with a partial mutation.
|
|
110
|
+
for (const skill of manifest.skills) {
|
|
111
|
+
validateSkillPath(skill.path);
|
|
112
|
+
}
|
|
87
113
|
let signed = 0;
|
|
88
114
|
let errors = 0;
|
|
89
115
|
|
|
@@ -103,7 +129,16 @@ function signAll() {
|
|
|
103
129
|
}
|
|
104
130
|
|
|
105
131
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
106
|
-
|
|
132
|
+
|
|
133
|
+
// S5: verdict line FIRST, fingerprint banner after. An operator
|
|
134
|
+
// scrolling output should not be able to see "fingerprint: SHA256..."
|
|
135
|
+
// and assume success when errors > 0.
|
|
136
|
+
if (errors > 0) {
|
|
137
|
+
console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
|
|
138
|
+
} else {
|
|
139
|
+
console.log(`\n[sign] ${signed} skills signed.`);
|
|
140
|
+
}
|
|
141
|
+
printFingerprintBanner();
|
|
107
142
|
|
|
108
143
|
if (errors > 0) process.exit(1);
|
|
109
144
|
}
|
|
@@ -118,6 +153,7 @@ function signOne(skillName) {
|
|
|
118
153
|
const skill = manifest.skills.find(s => s.name === skillName);
|
|
119
154
|
if (!skill) { console.error(`Skill not found: ${skillName}`); process.exit(1); }
|
|
120
155
|
|
|
156
|
+
validateSkillPath(skill.path);
|
|
121
157
|
const skillPath = path.join(ROOT, skill.path);
|
|
122
158
|
const content = fs.readFileSync(skillPath, 'utf8');
|
|
123
159
|
skill.signature = signContent(content, privateKey);
|
|
@@ -126,12 +162,69 @@ function signOne(skillName) {
|
|
|
126
162
|
|
|
127
163
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
128
164
|
console.log(`[sign] Signed: ${skillName}`);
|
|
165
|
+
printFingerprintBanner();
|
|
129
166
|
}
|
|
130
167
|
|
|
131
168
|
// --- helpers ---
|
|
132
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Normalize skill content for byte-stable signing.
|
|
172
|
+
*
|
|
173
|
+
* Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
|
|
174
|
+
* line endings to LF. lib/verify.js applies the exact same transform.
|
|
175
|
+
*
|
|
176
|
+
* Without this, a Windows checkout with core.autocrlf=true reads a
|
|
177
|
+
* skill with \r\n while CI reads the same skill with \n — same bytes
|
|
178
|
+
* on disk in git, different bytes in the working tree, different
|
|
179
|
+
* signature. v0.11.x shipped 0/38 verifies for exactly this reason.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} content
|
|
182
|
+
* @returns {string}
|
|
183
|
+
*/
|
|
184
|
+
function normalize(content) {
|
|
185
|
+
let s = content;
|
|
186
|
+
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
187
|
+
return s.replace(/\r\n/g, '\n');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validate a manifest skill.path entry to prevent path traversal.
|
|
192
|
+
*
|
|
193
|
+
* skill.path MUST be a string.
|
|
194
|
+
* skill.path MUST start with "skills/".
|
|
195
|
+
* skill.path MUST NOT contain "..".
|
|
196
|
+
* skill.path MUST NOT contain backslashes (POSIX-style forward slashes
|
|
197
|
+
* only — manifest paths are not platform-specific).
|
|
198
|
+
*
|
|
199
|
+
* A tampered manifest with "../../../etc/passwd" or
|
|
200
|
+
* "skills/foo/../../.keys/private.pem" is refused; the whole run
|
|
201
|
+
* aborts before any file I/O.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} skillPath
|
|
204
|
+
* @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
function validateSkillPath(skillPath) {
|
|
207
|
+
if (typeof skillPath !== 'string') {
|
|
208
|
+
throw new Error(`[sign] manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
209
|
+
}
|
|
210
|
+
// Backslash check runs BEFORE the prefix check so a Windows-style
|
|
211
|
+
// path ("skills\foo\skill.md") returns the clearer "use forward
|
|
212
|
+
// slashes" diagnostic, not the misleading "must start with skills/".
|
|
213
|
+
if (skillPath.includes('\\')) {
|
|
214
|
+
throw new Error(`[sign] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
|
|
215
|
+
}
|
|
216
|
+
if (!skillPath.startsWith('skills/')) {
|
|
217
|
+
throw new Error(`[sign] manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
|
|
218
|
+
}
|
|
219
|
+
if (skillPath.includes('..')) {
|
|
220
|
+
throw new Error(`[sign] manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
|
|
221
|
+
}
|
|
222
|
+
return skillPath;
|
|
223
|
+
}
|
|
224
|
+
|
|
133
225
|
function signContent(content, privateKey) {
|
|
134
|
-
const
|
|
226
|
+
const normalized = normalize(content);
|
|
227
|
+
const signature = crypto.sign(null, Buffer.from(normalized, 'utf8'), {
|
|
135
228
|
key: privateKey,
|
|
136
229
|
dsaEncoding: 'ieee-p1363'
|
|
137
230
|
});
|
|
@@ -151,6 +244,22 @@ function loadManifest() {
|
|
|
151
244
|
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
152
245
|
}
|
|
153
246
|
|
|
247
|
+
function printFingerprintBanner() {
|
|
248
|
+
if (!fs.existsSync(PUBLIC_KEY_PATH)) return;
|
|
249
|
+
try {
|
|
250
|
+
const pem = fs.readFileSync(PUBLIC_KEY_PATH, 'utf8');
|
|
251
|
+
const keyObj = crypto.createPublicKey(pem);
|
|
252
|
+
const der = keyObj.export({ type: 'spki', format: 'der' });
|
|
253
|
+
const sha256 = 'SHA256:' + crypto.createHash('sha256').update(der).digest('base64');
|
|
254
|
+
const sha3_512 = 'SHA3-512:' + crypto.createHash('sha3-512').update(der).digest('base64');
|
|
255
|
+
console.log(`[sign] Public key: keys/public.pem`);
|
|
256
|
+
console.log(`[sign] ${sha256}`);
|
|
257
|
+
console.log(`[sign] ${sha3_512}`);
|
|
258
|
+
} catch (_) {
|
|
259
|
+
// Best-effort banner — never let a fingerprint failure poison the run.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
154
263
|
// --- CLI ---
|
|
155
264
|
|
|
156
265
|
if (require.main === module) {
|
|
@@ -194,4 +303,4 @@ Signing ceremony (first time):
|
|
|
194
303
|
}
|
|
195
304
|
}
|
|
196
305
|
|
|
197
|
-
module.exports = { generateKeypair, signAll, signOne };
|
|
306
|
+
module.exports = { generateKeypair, signAll, signOne, normalize, validateSkillPath };
|
|
@@ -31,6 +31,40 @@ const REPO_ROOT = path.resolve(__dirname, '..');
|
|
|
31
31
|
const SCHEMA_PATH = path.join(REPO_ROOT, 'lib', 'schemas', 'cve-catalog.schema.json');
|
|
32
32
|
const CATALOG_PATH = path.join(REPO_ROOT, 'data', 'cve-catalog.json');
|
|
33
33
|
const LESSONS_PATH = path.join(REPO_ROOT, 'data', 'zeroday-lessons.json');
|
|
34
|
+
const ATLAS_PATH = path.join(REPO_ROOT, 'data', 'atlas-ttps.json');
|
|
35
|
+
const CWE_PATH = path.join(REPO_ROOT, 'data', 'cwe-catalog.json');
|
|
36
|
+
|
|
37
|
+
// v0.12.12 — patterns that mark a verification_sources URL as a public exploit
|
|
38
|
+
// or PoC location. When poc_available: true AND a verification source matches
|
|
39
|
+
// one of these, the entry must carry an `iocs` block per AGENTS.md Hard Rule
|
|
40
|
+
// #14. Surfaced as WARNING-only for v0.12.12 so drafts and pre-IoC entries
|
|
41
|
+
// don't break patch-class compatibility; v0.13.0 will tighten to error.
|
|
42
|
+
const PUBLIC_EXPLOIT_URL_PATTERNS = [
|
|
43
|
+
/github\.com\/.+\/(exploits?|poc|pocs)\b/i,
|
|
44
|
+
/\bexploit-?db\.com\b/i,
|
|
45
|
+
/\bpacketstormsecurity\.com\b/i,
|
|
46
|
+
/\bmetasploit\b/i,
|
|
47
|
+
/\/poc\//i,
|
|
48
|
+
/-poc\b/i,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// v0.12.12 — Tightened CVSS-vector prefix. Schema's existing pattern accepts
|
|
52
|
+
// any "CVSS:<digits>/"; the strict pattern below admits only known CVSS
|
|
53
|
+
// versions (2.0 / 3.0 / 3.1 / 4.0). Emitted as WARNING for v0.12.12; v0.13.0
|
|
54
|
+
// will tighten the schema itself.
|
|
55
|
+
const STRICT_CVSS_PATTERN = /^CVSS:(2\.0|3\.[01]|4\.0)\//;
|
|
56
|
+
|
|
57
|
+
// v0.12.12 — Impossible-date guard. Reject obviously bogus year ranges
|
|
58
|
+
// (typos like 1014 or 20262) without rejecting legitimate ISO dates.
|
|
59
|
+
const MIN_VALID_YEAR = 1990;
|
|
60
|
+
const MAX_VALID_YEAR = 2100;
|
|
61
|
+
const DATE_FIELDS = [
|
|
62
|
+
'last_updated',
|
|
63
|
+
'source_verified',
|
|
64
|
+
'cisa_kev_date',
|
|
65
|
+
'cisa_kev_due_date',
|
|
66
|
+
'epss_date',
|
|
67
|
+
];
|
|
34
68
|
|
|
35
69
|
function parseArgs(argv) {
|
|
36
70
|
const opts = { quiet: false };
|
|
@@ -162,17 +196,126 @@ function validate(value, schema, schemaName, pathStr) {
|
|
|
162
196
|
return errors;
|
|
163
197
|
}
|
|
164
198
|
|
|
199
|
+
function looksLikePublicExploitSource(url) {
|
|
200
|
+
if (typeof url !== 'string') return false;
|
|
201
|
+
return PUBLIC_EXPLOIT_URL_PATTERNS.some((re) => re.test(url));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isUsableDate(value) {
|
|
205
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
206
|
+
return { ok: false, reason: 'not in YYYY-MM-DD shape' };
|
|
207
|
+
}
|
|
208
|
+
const d = new Date(value + 'T00:00:00Z');
|
|
209
|
+
if (Number.isNaN(d.getTime())) return { ok: false, reason: 'unparseable' };
|
|
210
|
+
const year = Number(value.slice(0, 4));
|
|
211
|
+
if (year < MIN_VALID_YEAR || year > MAX_VALID_YEAR) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
reason: `year ${year} outside ${MIN_VALID_YEAR}..${MAX_VALID_YEAR}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { ok: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function additionalChecks(key, entry, ctx) {
|
|
221
|
+
const warnings = [];
|
|
222
|
+
|
|
223
|
+
// V1 — Hard Rule #14 conditional: poc + public-exploit URL → iocs required.
|
|
224
|
+
if (entry.poc_available === true) {
|
|
225
|
+
const sources = Array.isArray(entry.verification_sources)
|
|
226
|
+
? entry.verification_sources
|
|
227
|
+
: [];
|
|
228
|
+
const hasPublicExploitSource = sources.some(looksLikePublicExploitSource);
|
|
229
|
+
if (hasPublicExploitSource) {
|
|
230
|
+
const iocs = entry.iocs;
|
|
231
|
+
const iocsPopulated =
|
|
232
|
+
iocs && typeof iocs === 'object' && !Array.isArray(iocs) && Object.keys(iocs).length > 0;
|
|
233
|
+
if (!iocsPopulated) {
|
|
234
|
+
warnings.push(
|
|
235
|
+
`${key}: poc_available=true and verification_sources includes a public-exploit URL, but iocs is missing or empty (AGENTS.md Hard Rule #14)`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// V2 — Cross-catalog reference resolution. Unresolved refs are warnings
|
|
242
|
+
// for v0.12.12; v0.13.0 will flip to hard failures.
|
|
243
|
+
for (const ref of entry.atlas_refs || []) {
|
|
244
|
+
if (!ctx.atlasKeys.has(ref)) {
|
|
245
|
+
warnings.push(
|
|
246
|
+
`${key}: atlas_refs entry "${ref}" not in data/atlas-ttps.json (will hard-fail in v0.13.0)`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const ref of entry.cwe_refs || []) {
|
|
251
|
+
if (!ctx.cweKeys.has(ref)) {
|
|
252
|
+
warnings.push(
|
|
253
|
+
`${key}: cwe_refs entry "${ref}" not in data/cwe-catalog.json (will hard-fail in v0.13.0)`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// V4 — Impossible-date guard.
|
|
259
|
+
for (const f of DATE_FIELDS) {
|
|
260
|
+
const v = entry[f];
|
|
261
|
+
if (v === undefined || v === null) continue;
|
|
262
|
+
const r = isUsableDate(v);
|
|
263
|
+
if (!r.ok) {
|
|
264
|
+
warnings.push(`${key}: ${f} value ${JSON.stringify(v)} is invalid (${r.reason})`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Sch1 — strict CVSS-vector prefix (warning-only for v0.12.12). The schema
|
|
269
|
+
// pattern stays loose; this admits only known CVSS versions.
|
|
270
|
+
if (typeof entry.cvss_vector === 'string' && entry.cvss_vector.length > 0) {
|
|
271
|
+
if (!STRICT_CVSS_PATTERN.test(entry.cvss_vector)) {
|
|
272
|
+
warnings.push(
|
|
273
|
+
`${key}: cvss_vector ${JSON.stringify(entry.cvss_vector)} does not match the strict prefix /^CVSS:(2.0|3.0|3.1|4.0)\\//. Schema tolerates this in v0.12.12; v0.13.0 will tighten the schema.`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return warnings;
|
|
279
|
+
}
|
|
280
|
+
|
|
165
281
|
function main() {
|
|
166
282
|
const opts = parseArgs(process.argv);
|
|
167
283
|
const schema = readJson(SCHEMA_PATH);
|
|
168
284
|
const catalog = readJson(CATALOG_PATH);
|
|
169
285
|
const lessons = readJson(LESSONS_PATH);
|
|
286
|
+
const atlas = fs.existsSync(ATLAS_PATH) ? readJson(ATLAS_PATH) : {};
|
|
287
|
+
const cwe = fs.existsSync(CWE_PATH) ? readJson(CWE_PATH) : {};
|
|
288
|
+
|
|
289
|
+
const ctx = {
|
|
290
|
+
atlasKeys: new Set(Object.keys(atlas).filter((k) => !k.startsWith('_'))),
|
|
291
|
+
cweKeys: new Set(Object.keys(cwe).filter((k) => !k.startsWith('_'))),
|
|
292
|
+
};
|
|
170
293
|
|
|
171
294
|
const cveKeys = Object.keys(catalog).filter((k) => !k.startsWith('_'));
|
|
172
295
|
const lessonKeys = new Set(Object.keys(lessons).filter((k) => !k.startsWith('_')));
|
|
173
296
|
|
|
174
297
|
let failed = 0;
|
|
175
298
|
let drafts = 0;
|
|
299
|
+
let warned = 0;
|
|
300
|
+
|
|
301
|
+
// V3 — Duplicate-name detection across all non-_meta entries.
|
|
302
|
+
const nameToKeys = new Map();
|
|
303
|
+
for (const k of cveKeys) {
|
|
304
|
+
const n = catalog[k] && catalog[k].name;
|
|
305
|
+
if (typeof n === 'string' && n.length > 0) {
|
|
306
|
+
if (!nameToKeys.has(n)) nameToKeys.set(n, []);
|
|
307
|
+
nameToKeys.get(n).push(k);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const dupNameWarnings = [];
|
|
311
|
+
for (const [n, ks] of nameToKeys) {
|
|
312
|
+
if (ks.length > 1) {
|
|
313
|
+
dupNameWarnings.push(
|
|
314
|
+
`duplicate CVE name ${JSON.stringify(n)} across keys: ${ks.join(', ')}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
176
319
|
for (const key of cveKeys) {
|
|
177
320
|
const entry = catalog[key];
|
|
178
321
|
// v0.12.0: GHSA-imported drafts are flagged `_auto_imported: true` +
|
|
@@ -182,6 +325,7 @@ function main() {
|
|
|
182
325
|
// `exceptd run cve-curation --advisory <id>`.
|
|
183
326
|
const isDraft = entry && (entry._auto_imported === true || entry._draft === true);
|
|
184
327
|
const errors = validate(entry, schema, 'cve', key);
|
|
328
|
+
const warnings = additionalChecks(key, entry, ctx);
|
|
185
329
|
if (!lessonKeys.has(key) && !isDraft) {
|
|
186
330
|
errors.push(
|
|
187
331
|
`${key}: missing matching entry in data/zeroday-lessons.json (rule #6: zero-day learning is live)`,
|
|
@@ -192,16 +336,22 @@ function main() {
|
|
|
192
336
|
if (!opts.quiet) {
|
|
193
337
|
console.log(`DRAFT ${key} (auto-imported — needs editorial review)`);
|
|
194
338
|
for (const e of errors) console.log(` - [warn] ${e}`);
|
|
339
|
+
for (const w of warnings) console.log(` - [warn] ${w}`);
|
|
195
340
|
}
|
|
196
341
|
// Drafts don't increment `failed` — they're warnings, not errors.
|
|
197
342
|
continue;
|
|
198
343
|
}
|
|
199
|
-
if (errors.length === 0) {
|
|
344
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
200
345
|
if (!opts.quiet) console.log(`PASS ${key}`);
|
|
346
|
+
} else if (errors.length === 0) {
|
|
347
|
+
warned++;
|
|
348
|
+
if (!opts.quiet) console.log(`WARN ${key}`);
|
|
349
|
+
for (const w of warnings) console.log(` - [warn] ${w}`);
|
|
201
350
|
} else {
|
|
202
351
|
failed++;
|
|
203
352
|
console.log(`FAIL ${key}`);
|
|
204
353
|
for (const e of errors) console.log(` - ${e}`);
|
|
354
|
+
for (const w of warnings) console.log(` - [warn] ${w}`);
|
|
205
355
|
}
|
|
206
356
|
}
|
|
207
357
|
|
|
@@ -218,13 +368,31 @@ function main() {
|
|
|
218
368
|
}
|
|
219
369
|
}
|
|
220
370
|
|
|
371
|
+
// V3 — emit duplicate-name warnings as a catalog-wide tail block.
|
|
372
|
+
for (const w of dupNameWarnings) {
|
|
373
|
+
console.log(`WARN catalog`);
|
|
374
|
+
console.log(` - [warn] ${w}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
221
377
|
const total = cveKeys.length;
|
|
222
|
-
const passed = total - failed - drafts;
|
|
378
|
+
const passed = total - failed - drafts - warned;
|
|
223
379
|
const summary = `\n${passed}/${total} CVE entries validated` +
|
|
224
380
|
(drafts ? `, ${drafts} draft(s) (auto-imported)` : '') +
|
|
381
|
+
(warned ? `, ${warned} with warnings` : '') +
|
|
225
382
|
(failed ? `, ${failed} failed` : '') + '.';
|
|
226
383
|
console.log(summary);
|
|
227
384
|
process.exit(failed === 0 ? 0 : 1);
|
|
228
385
|
}
|
|
229
386
|
|
|
230
|
-
|
|
387
|
+
module.exports = {
|
|
388
|
+
validate,
|
|
389
|
+
looksLikePublicExploitSource,
|
|
390
|
+
isUsableDate,
|
|
391
|
+
additionalChecks,
|
|
392
|
+
PUBLIC_EXPLOIT_URL_PATTERNS,
|
|
393
|
+
STRICT_CVSS_PATTERN,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
if (require.main === module) {
|
|
397
|
+
main();
|
|
398
|
+
}
|