@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/lib/scoring.js CHANGED
@@ -34,7 +34,62 @@ function score(cveId, catalog) {
34
34
  return entry.rwep_score;
35
35
  }
36
36
 
37
- function scoreCustom(factors) {
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
- score += Math.min(RWEP_WEIGHTS.blast_radius, blast_radius);
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
- return Math.min(100, Math.max(0, score));
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
- console.log(`\n[sign] ${signed} skills signed. ${errors} errors.`);
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 signature = crypto.sign(null, Buffer.from(content, 'utf8'), {
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
- main();
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
+ }