@blamejs/exceptd-skills 0.12.11 → 0.12.15
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 +243 -0
- package/bin/exceptd.js +299 -48
- package/data/_indexes/_meta.json +49 -48
- package/data/_indexes/activity-feed.json +13 -5
- package/data/_indexes/catalog-summaries.json +51 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +339 -0
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +505 -47
- package/lib/lint-skills.js +217 -15
- package/lib/playbook-runner.js +1224 -183
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +261 -95
- package/lib/refresh-network.js +208 -18
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +83 -7
- package/lib/sign.js +112 -3
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +213 -7
- package/lib/validate-indexes.js +88 -37
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +313 -16
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +196 -20
- package/package.json +3 -1
- package/sbom.cdx.json +9 -9
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +110 -40
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
|
@@ -52,15 +52,17 @@ const PLACEHOLDER_TOKENS = [
|
|
|
52
52
|
];
|
|
53
53
|
|
|
54
54
|
function parseArgs(argv) {
|
|
55
|
-
const opts = { quiet: false };
|
|
55
|
+
const opts = { quiet: false, strict: false };
|
|
56
56
|
for (let i = 2; i < argv.length; i++) {
|
|
57
57
|
const a = argv[i];
|
|
58
58
|
if (a === '--quiet' || a === '-q') opts.quiet = true;
|
|
59
|
+
else if (a === '--strict') opts.strict = true;
|
|
59
60
|
else if (a === '--help' || a === '-h') {
|
|
60
61
|
console.log(
|
|
61
|
-
'Usage: node lib/validate-catalog-meta.js [--quiet]\n' +
|
|
62
|
+
'Usage: node lib/validate-catalog-meta.js [--quiet] [--strict]\n' +
|
|
62
63
|
'\n' +
|
|
63
|
-
' --quiet Suppress per-catalog PASS output; show failures only.\n'
|
|
64
|
+
' --quiet Suppress per-catalog PASS output; show failures only.\n' +
|
|
65
|
+
' --strict Promote v0.13.0-preview warnings (freshness) to errors.\n',
|
|
64
66
|
);
|
|
65
67
|
process.exit(0);
|
|
66
68
|
} else {
|
|
@@ -80,8 +82,9 @@ function containsPlaceholder(s) {
|
|
|
80
82
|
return PLACEHOLDER_TOKENS.some((re) => re.test(s));
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
function validateMeta(catalogPath) {
|
|
85
|
+
function validateMeta(catalogPath, opts) {
|
|
84
86
|
const errors = [];
|
|
87
|
+
const warnings = [];
|
|
85
88
|
const data = readJson(catalogPath);
|
|
86
89
|
const meta = data._meta;
|
|
87
90
|
|
|
@@ -159,8 +162,46 @@ function validateMeta(catalogPath) {
|
|
|
159
162
|
);
|
|
160
163
|
}
|
|
161
164
|
}
|
|
165
|
+
|
|
166
|
+
/* Audit G F3 — freshness enforcement. When both meta.last_updated and
|
|
167
|
+
* freshness_policy.stale_after_days are present, surface a warning if
|
|
168
|
+
* (now - last_updated) > stale_after_days. Patch-class release emits at
|
|
169
|
+
* WARN level (does not fail validation); v0.13.0 will flip to an error.
|
|
170
|
+
*
|
|
171
|
+
* Optional `opts.strict` (or `opts.errorOnStale`) promotes the warning
|
|
172
|
+
* to an error today; predeploy keeps the warning posture.
|
|
173
|
+
*/
|
|
174
|
+
if (
|
|
175
|
+
typeof meta.last_updated === 'string' &&
|
|
176
|
+
typeof fp.stale_after_days === 'number' &&
|
|
177
|
+
fp.stale_after_days > 0
|
|
178
|
+
) {
|
|
179
|
+
const lu = new Date(meta.last_updated + (
|
|
180
|
+
/^\d{4}-\d{2}-\d{2}$/.test(meta.last_updated) ? 'T00:00:00Z' : ''
|
|
181
|
+
));
|
|
182
|
+
if (!Number.isNaN(lu.getTime())) {
|
|
183
|
+
const ageDays = Math.floor((Date.now() - lu.getTime()) / 86400000);
|
|
184
|
+
if (ageDays > fp.stale_after_days) {
|
|
185
|
+
const msg =
|
|
186
|
+
`_meta freshness: last_updated ${meta.last_updated} is ${ageDays} days old ` +
|
|
187
|
+
`(stale_after_days = ${fp.stale_after_days}); refresh the catalog or bump _meta.last_updated. ` +
|
|
188
|
+
`Will hard-fail in v0.13.0.`;
|
|
189
|
+
if (opts && (opts.strict || opts.errorOnStale)) {
|
|
190
|
+
errors.push(msg);
|
|
191
|
+
} else {
|
|
192
|
+
warnings.push(msg);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
162
197
|
}
|
|
163
198
|
|
|
199
|
+
// Warnings are appended after errors when callers ask for the combined
|
|
200
|
+
// shape via opts.includeWarnings. Default return is errors only so the
|
|
201
|
+
// public function signature is unchanged for existing callers.
|
|
202
|
+
if (opts && opts.includeWarnings) {
|
|
203
|
+
return { errors, warnings };
|
|
204
|
+
}
|
|
164
205
|
return errors;
|
|
165
206
|
}
|
|
166
207
|
|
|
@@ -172,23 +213,37 @@ function main() {
|
|
|
172
213
|
.sort();
|
|
173
214
|
|
|
174
215
|
let failed = 0;
|
|
216
|
+
let warned = 0;
|
|
175
217
|
for (const f of files) {
|
|
176
|
-
const
|
|
177
|
-
|
|
218
|
+
const result = validateMeta(path.join(DATA_DIR, f), {
|
|
219
|
+
includeWarnings: true,
|
|
220
|
+
strict: opts.strict,
|
|
221
|
+
});
|
|
222
|
+
const errors = result.errors;
|
|
223
|
+
const warnings = result.warnings || [];
|
|
224
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
178
225
|
if (!opts.quiet) console.log(`PASS ${f}`);
|
|
226
|
+
} else if (errors.length === 0) {
|
|
227
|
+
warned++;
|
|
228
|
+
if (!opts.quiet) console.log(`WARN ${f}`);
|
|
229
|
+
for (const w of warnings) console.log(` - [warn] ${w}`);
|
|
179
230
|
} else {
|
|
180
231
|
failed++;
|
|
181
232
|
console.log(`FAIL ${f}`);
|
|
182
233
|
for (const e of errors) console.log(` - ${e}`);
|
|
234
|
+
for (const w of warnings) console.log(` - [warn] ${w}`);
|
|
183
235
|
}
|
|
184
236
|
}
|
|
185
237
|
|
|
186
238
|
const total = files.length;
|
|
187
|
-
const passed = total - failed;
|
|
239
|
+
const passed = total - failed - warned;
|
|
240
|
+
const warnSuffix = warned ? `, ${warned} with warnings` : '';
|
|
241
|
+
const failSuffix = failed ? `, ${failed} failed` : '';
|
|
188
242
|
console.log(
|
|
189
|
-
`\n${passed}/${total} catalogs validated${
|
|
243
|
+
`\n${passed}/${total} catalogs validated${warnSuffix}${failSuffix}.`,
|
|
190
244
|
);
|
|
191
|
-
process.
|
|
245
|
+
// F18: process.exitCode + return so buffered writes drain.
|
|
246
|
+
process.exitCode = failed === 0 ? 0 : 1;
|
|
192
247
|
}
|
|
193
248
|
|
|
194
249
|
if (require.main === module) {
|
|
@@ -31,17 +31,56 @@ 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
|
+
const ATTACK_PATH = path.join(REPO_ROOT, 'data', 'attack-techniques.json');
|
|
37
|
+
const D3FEND_PATH = path.join(REPO_ROOT, 'data', 'd3fend-catalog.json');
|
|
38
|
+
const FRAMEWORK_GAPS_PATH = path.join(REPO_ROOT, 'data', 'framework-control-gaps.json');
|
|
39
|
+
|
|
40
|
+
// v0.12.12 — patterns that mark a verification_sources URL as a public exploit
|
|
41
|
+
// or PoC location. When poc_available: true AND a verification source matches
|
|
42
|
+
// one of these, the entry must carry an `iocs` block per AGENTS.md Hard Rule
|
|
43
|
+
// #14. Surfaced as WARNING-only for v0.12.12 so drafts and pre-IoC entries
|
|
44
|
+
// don't break patch-class compatibility; v0.13.0 will tighten to error.
|
|
45
|
+
const PUBLIC_EXPLOIT_URL_PATTERNS = [
|
|
46
|
+
/github\.com\/.+\/(exploits?|poc|pocs)\b/i,
|
|
47
|
+
/\bexploit-?db\.com\b/i,
|
|
48
|
+
/\bpacketstormsecurity\.com\b/i,
|
|
49
|
+
/\bmetasploit\b/i,
|
|
50
|
+
/\/poc\//i,
|
|
51
|
+
/-poc\b/i,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// v0.12.12 — Tightened CVSS-vector prefix. Schema's existing pattern accepts
|
|
55
|
+
// any "CVSS:<digits>/"; the strict pattern below admits only known CVSS
|
|
56
|
+
// versions (2.0 / 3.0 / 3.1 / 4.0). Emitted as WARNING for v0.12.12; v0.13.0
|
|
57
|
+
// will tighten the schema itself.
|
|
58
|
+
const STRICT_CVSS_PATTERN = /^CVSS:(2\.0|3\.[01]|4\.0)\//;
|
|
59
|
+
|
|
60
|
+
// v0.12.12 — Impossible-date guard. Reject obviously bogus year ranges
|
|
61
|
+
// (typos like 1014 or 20262) without rejecting legitimate ISO dates.
|
|
62
|
+
const MIN_VALID_YEAR = 1990;
|
|
63
|
+
const MAX_VALID_YEAR = 2100;
|
|
64
|
+
const DATE_FIELDS = [
|
|
65
|
+
'last_updated',
|
|
66
|
+
'source_verified',
|
|
67
|
+
'cisa_kev_date',
|
|
68
|
+
'cisa_kev_due_date',
|
|
69
|
+
'epss_date',
|
|
70
|
+
];
|
|
34
71
|
|
|
35
72
|
function parseArgs(argv) {
|
|
36
|
-
const opts = { quiet: false };
|
|
73
|
+
const opts = { quiet: false, strict: false };
|
|
37
74
|
for (let i = 2; i < argv.length; i++) {
|
|
38
75
|
const a = argv[i];
|
|
39
76
|
if (a === '--quiet' || a === '-q') opts.quiet = true;
|
|
77
|
+
else if (a === '--strict') opts.strict = true;
|
|
40
78
|
else if (a === '--help' || a === '-h') {
|
|
41
79
|
console.log(
|
|
42
|
-
'Usage: node lib/validate-cve-catalog.js [--quiet]\n' +
|
|
80
|
+
'Usage: node lib/validate-cve-catalog.js [--quiet] [--strict]\n' +
|
|
43
81
|
'\n' +
|
|
44
|
-
' --quiet Suppress per-CVE PASS output; show failures only.\n'
|
|
82
|
+
' --quiet Suppress per-CVE PASS output; show failures only.\n' +
|
|
83
|
+
' --strict Promote v0.13.0-preview warnings to errors. Off by default.\n',
|
|
45
84
|
);
|
|
46
85
|
process.exit(0);
|
|
47
86
|
} else {
|
|
@@ -162,17 +201,152 @@ function validate(value, schema, schemaName, pathStr) {
|
|
|
162
201
|
return errors;
|
|
163
202
|
}
|
|
164
203
|
|
|
204
|
+
function looksLikePublicExploitSource(url) {
|
|
205
|
+
if (typeof url !== 'string') return false;
|
|
206
|
+
return PUBLIC_EXPLOIT_URL_PATTERNS.some((re) => re.test(url));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isUsableDate(value) {
|
|
210
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
211
|
+
return { ok: false, reason: 'not in YYYY-MM-DD shape' };
|
|
212
|
+
}
|
|
213
|
+
const d = new Date(value + 'T00:00:00Z');
|
|
214
|
+
if (Number.isNaN(d.getTime())) return { ok: false, reason: 'unparseable' };
|
|
215
|
+
const year = Number(value.slice(0, 4));
|
|
216
|
+
if (year < MIN_VALID_YEAR || year > MAX_VALID_YEAR) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
reason: `year ${year} outside ${MIN_VALID_YEAR}..${MAX_VALID_YEAR}`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return { ok: true };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function additionalChecks(key, entry, ctx) {
|
|
226
|
+
const warnings = [];
|
|
227
|
+
|
|
228
|
+
// V1 — Hard Rule #14 conditional: poc + public-exploit URL → iocs required.
|
|
229
|
+
if (entry.poc_available === true) {
|
|
230
|
+
const sources = Array.isArray(entry.verification_sources)
|
|
231
|
+
? entry.verification_sources
|
|
232
|
+
: [];
|
|
233
|
+
const hasPublicExploitSource = sources.some(looksLikePublicExploitSource);
|
|
234
|
+
if (hasPublicExploitSource) {
|
|
235
|
+
const iocs = entry.iocs;
|
|
236
|
+
const iocsPopulated =
|
|
237
|
+
iocs && typeof iocs === 'object' && !Array.isArray(iocs) && Object.keys(iocs).length > 0;
|
|
238
|
+
if (!iocsPopulated) {
|
|
239
|
+
warnings.push(
|
|
240
|
+
`${key}: poc_available=true and verification_sources includes a public-exploit URL, but iocs is missing or empty (AGENTS.md Hard Rule #14)`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// V2 — Cross-catalog reference resolution. Unresolved refs are warnings
|
|
247
|
+
// for v0.12.x; v0.13.0 will flip to hard failures. Audit D's V2 expansion
|
|
248
|
+
// (Audit G) extends the walk from cwe_refs only to attack_refs, atlas_refs,
|
|
249
|
+
// d3fend_refs, AND framework_control_gaps.
|
|
250
|
+
const REF_FIELDS = [
|
|
251
|
+
{ field: 'atlas_refs', set: ctx.atlasKeys, file: 'data/atlas-ttps.json' },
|
|
252
|
+
{ field: 'cwe_refs', set: ctx.cweKeys, file: 'data/cwe-catalog.json' },
|
|
253
|
+
{ field: 'attack_refs', set: ctx.attackKeys, file: 'data/attack-techniques.json' },
|
|
254
|
+
{ field: 'd3fend_refs', set: ctx.d3fendKeys, file: 'data/d3fend-catalog.json' },
|
|
255
|
+
{
|
|
256
|
+
field: 'framework_control_gaps',
|
|
257
|
+
set: ctx.frameworkKeys,
|
|
258
|
+
file: 'data/framework-control-gaps.json',
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
for (const { field, set, file } of REF_FIELDS) {
|
|
262
|
+
if (!set) continue; // catalog absent — skip silently (defense-in-depth)
|
|
263
|
+
const refs = entry[field];
|
|
264
|
+
if (!Array.isArray(refs)) continue;
|
|
265
|
+
for (const ref of refs) {
|
|
266
|
+
if (typeof ref !== 'string') continue;
|
|
267
|
+
if (!set.has(ref)) {
|
|
268
|
+
warnings.push(
|
|
269
|
+
`${key}: ${field} entry "${ref}" not in ${file} (will hard-fail in v0.13.0)`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// V4 — Impossible-date guard.
|
|
276
|
+
for (const f of DATE_FIELDS) {
|
|
277
|
+
const v = entry[f];
|
|
278
|
+
if (v === undefined || v === null) continue;
|
|
279
|
+
const r = isUsableDate(v);
|
|
280
|
+
if (!r.ok) {
|
|
281
|
+
warnings.push(`${key}: ${f} value ${JSON.stringify(v)} is invalid (${r.reason})`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Sch1 — strict CVSS-vector prefix (warning-only for v0.12.12). The schema
|
|
286
|
+
// pattern stays loose; this admits only known CVSS versions.
|
|
287
|
+
if (typeof entry.cvss_vector === 'string' && entry.cvss_vector.length > 0) {
|
|
288
|
+
if (!STRICT_CVSS_PATTERN.test(entry.cvss_vector)) {
|
|
289
|
+
warnings.push(
|
|
290
|
+
`${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.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return warnings;
|
|
296
|
+
}
|
|
297
|
+
|
|
165
298
|
function main() {
|
|
166
299
|
const opts = parseArgs(process.argv);
|
|
167
300
|
const schema = readJson(SCHEMA_PATH);
|
|
168
301
|
const catalog = readJson(CATALOG_PATH);
|
|
169
302
|
const lessons = readJson(LESSONS_PATH);
|
|
303
|
+
const atlas = fs.existsSync(ATLAS_PATH) ? readJson(ATLAS_PATH) : {};
|
|
304
|
+
const cwe = fs.existsSync(CWE_PATH) ? readJson(CWE_PATH) : {};
|
|
305
|
+
const attack = fs.existsSync(ATTACK_PATH) ? readJson(ATTACK_PATH) : null;
|
|
306
|
+
const d3fend = fs.existsSync(D3FEND_PATH) ? readJson(D3FEND_PATH) : null;
|
|
307
|
+
const frameworks = fs.existsSync(FRAMEWORK_GAPS_PATH)
|
|
308
|
+
? readJson(FRAMEWORK_GAPS_PATH)
|
|
309
|
+
: null;
|
|
310
|
+
|
|
311
|
+
const ctx = {
|
|
312
|
+
atlasKeys: new Set(Object.keys(atlas).filter((k) => !k.startsWith('_'))),
|
|
313
|
+
cweKeys: new Set(Object.keys(cwe).filter((k) => !k.startsWith('_'))),
|
|
314
|
+
attackKeys: attack
|
|
315
|
+
? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
|
|
316
|
+
: null,
|
|
317
|
+
d3fendKeys: d3fend
|
|
318
|
+
? new Set(Object.keys(d3fend).filter((k) => !k.startsWith('_')))
|
|
319
|
+
: null,
|
|
320
|
+
frameworkKeys: frameworks
|
|
321
|
+
? new Set(Object.keys(frameworks).filter((k) => !k.startsWith('_')))
|
|
322
|
+
: null,
|
|
323
|
+
};
|
|
170
324
|
|
|
171
325
|
const cveKeys = Object.keys(catalog).filter((k) => !k.startsWith('_'));
|
|
172
326
|
const lessonKeys = new Set(Object.keys(lessons).filter((k) => !k.startsWith('_')));
|
|
173
327
|
|
|
174
328
|
let failed = 0;
|
|
175
329
|
let drafts = 0;
|
|
330
|
+
let warned = 0;
|
|
331
|
+
|
|
332
|
+
// V3 — Duplicate-name detection across all non-_meta entries.
|
|
333
|
+
const nameToKeys = new Map();
|
|
334
|
+
for (const k of cveKeys) {
|
|
335
|
+
const n = catalog[k] && catalog[k].name;
|
|
336
|
+
if (typeof n === 'string' && n.length > 0) {
|
|
337
|
+
if (!nameToKeys.has(n)) nameToKeys.set(n, []);
|
|
338
|
+
nameToKeys.get(n).push(k);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const dupNameWarnings = [];
|
|
342
|
+
for (const [n, ks] of nameToKeys) {
|
|
343
|
+
if (ks.length > 1) {
|
|
344
|
+
dupNameWarnings.push(
|
|
345
|
+
`duplicate CVE name ${JSON.stringify(n)} across keys: ${ks.join(', ')}`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
176
350
|
for (const key of cveKeys) {
|
|
177
351
|
const entry = catalog[key];
|
|
178
352
|
// v0.12.0: GHSA-imported drafts are flagged `_auto_imported: true` +
|
|
@@ -182,26 +356,39 @@ function main() {
|
|
|
182
356
|
// `exceptd run cve-curation --advisory <id>`.
|
|
183
357
|
const isDraft = entry && (entry._auto_imported === true || entry._draft === true);
|
|
184
358
|
const errors = validate(entry, schema, 'cve', key);
|
|
359
|
+
let warnings = additionalChecks(key, entry, ctx);
|
|
185
360
|
if (!lessonKeys.has(key) && !isDraft) {
|
|
186
361
|
errors.push(
|
|
187
362
|
`${key}: missing matching entry in data/zeroday-lessons.json (rule #6: zero-day learning is live)`,
|
|
188
363
|
);
|
|
189
364
|
}
|
|
365
|
+
// F20 — --strict promotes per-CVE warnings to errors. Drafts are
|
|
366
|
+
// exempt (drafts already exit non-fail).
|
|
367
|
+
if (opts.strict && !isDraft) {
|
|
368
|
+
errors.push(...warnings);
|
|
369
|
+
warnings = [];
|
|
370
|
+
}
|
|
190
371
|
if (isDraft) {
|
|
191
372
|
drafts++;
|
|
192
373
|
if (!opts.quiet) {
|
|
193
374
|
console.log(`DRAFT ${key} (auto-imported — needs editorial review)`);
|
|
194
375
|
for (const e of errors) console.log(` - [warn] ${e}`);
|
|
376
|
+
for (const w of warnings) console.log(` - [warn] ${w}`);
|
|
195
377
|
}
|
|
196
378
|
// Drafts don't increment `failed` — they're warnings, not errors.
|
|
197
379
|
continue;
|
|
198
380
|
}
|
|
199
|
-
if (errors.length === 0) {
|
|
381
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
200
382
|
if (!opts.quiet) console.log(`PASS ${key}`);
|
|
383
|
+
} else if (errors.length === 0) {
|
|
384
|
+
warned++;
|
|
385
|
+
if (!opts.quiet) console.log(`WARN ${key}`);
|
|
386
|
+
for (const w of warnings) console.log(` - [warn] ${w}`);
|
|
201
387
|
} else {
|
|
202
388
|
failed++;
|
|
203
389
|
console.log(`FAIL ${key}`);
|
|
204
390
|
for (const e of errors) console.log(` - ${e}`);
|
|
391
|
+
for (const w of warnings) console.log(` - [warn] ${w}`);
|
|
205
392
|
}
|
|
206
393
|
}
|
|
207
394
|
|
|
@@ -218,13 +405,32 @@ function main() {
|
|
|
218
405
|
}
|
|
219
406
|
}
|
|
220
407
|
|
|
408
|
+
// V3 — emit duplicate-name warnings as a catalog-wide tail block.
|
|
409
|
+
for (const w of dupNameWarnings) {
|
|
410
|
+
console.log(`WARN catalog`);
|
|
411
|
+
console.log(` - [warn] ${w}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
221
414
|
const total = cveKeys.length;
|
|
222
|
-
const passed = total - failed - drafts;
|
|
415
|
+
const passed = total - failed - drafts - warned;
|
|
223
416
|
const summary = `\n${passed}/${total} CVE entries validated` +
|
|
224
417
|
(drafts ? `, ${drafts} draft(s) (auto-imported)` : '') +
|
|
418
|
+
(warned ? `, ${warned} with warnings` : '') +
|
|
225
419
|
(failed ? `, ${failed} failed` : '') + '.';
|
|
226
420
|
console.log(summary);
|
|
227
|
-
process.
|
|
421
|
+
// F18: process.exitCode + return so buffered output drains.
|
|
422
|
+
process.exitCode = failed === 0 ? 0 : 1;
|
|
228
423
|
}
|
|
229
424
|
|
|
230
|
-
|
|
425
|
+
module.exports = {
|
|
426
|
+
validate,
|
|
427
|
+
looksLikePublicExploitSource,
|
|
428
|
+
isUsableDate,
|
|
429
|
+
additionalChecks,
|
|
430
|
+
PUBLIC_EXPLOIT_URL_PATTERNS,
|
|
431
|
+
STRICT_CVSS_PATTERN,
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
if (require.main === module) {
|
|
435
|
+
main();
|
|
436
|
+
}
|
package/lib/validate-indexes.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* 3. Fail if any hash diverges (the indexes are stale).
|
|
13
13
|
* 4. Fail if a new source file exists that's not in the index (the
|
|
14
14
|
* index doesn't reflect current state).
|
|
15
|
+
* 5. Fail if source_hashes is empty (build-indexes never ran).
|
|
16
|
+
* 6. Fail if any data/*.json or listed source is a symlink.
|
|
15
17
|
*
|
|
16
18
|
* Exit 0 on success, 1 on staleness.
|
|
17
19
|
*
|
|
@@ -34,50 +36,99 @@ function sha256(buf) {
|
|
|
34
36
|
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
function main() {
|
|
40
|
+
if (!fs.existsSync(META)) {
|
|
41
|
+
console.error("[validate-indexes] data/_indexes/_meta.json missing — run `npm run build-indexes`.");
|
|
42
|
+
// v0.11.13 pattern: exitCode + return so async stdout/stderr writes drain.
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
41
46
|
|
|
42
|
-
const meta = JSON.parse(fs.readFileSync(META, "utf8"));
|
|
43
|
-
const recorded = meta.source_hashes || {};
|
|
47
|
+
const meta = JSON.parse(fs.readFileSync(META, "utf8"));
|
|
48
|
+
const recorded = meta.source_hashes || {};
|
|
44
49
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// Audit G F1 — reject an empty source_hashes table outright. The previous
|
|
51
|
+
// gate would silently pass when source_hashes was {} (or missing entirely)
|
|
52
|
+
// because the for-loop body never executed; the resulting "0 sources" pass
|
|
53
|
+
// banner falsely advertised the indexes as current. An empty source-hash
|
|
54
|
+
// table means build-indexes was never run, or was run against an empty
|
|
55
|
+
// repo, and the index files themselves are not trustworthy.
|
|
56
|
+
if (Object.keys(recorded).length === 0) {
|
|
57
|
+
console.error(
|
|
58
|
+
"[validate-indexes] data/_indexes/_meta.json source_hashes is empty — " +
|
|
59
|
+
"this means build-indexes did not populate the index. " +
|
|
60
|
+
"Regenerate with: npm run build-indexes"
|
|
61
|
+
);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Discover the current canonical source set.
|
|
67
|
+
const manifest = JSON.parse(fs.readFileSync(ABS("manifest.json"), "utf8"));
|
|
68
|
+
const liveSources = new Set();
|
|
69
|
+
liveSources.add("manifest.json");
|
|
70
|
+
// Audit G F16 — use lstat to detect symlinks. A symlinked .json under data/
|
|
71
|
+
// would be hashed via the followed target, allowing a malicious checkout
|
|
72
|
+
// (or a misconfigured filesystem) to swap data origin without tripping the
|
|
73
|
+
// gate. Reject symlinks outright.
|
|
74
|
+
for (const f of fs.readdirSync(ABS("data"))) {
|
|
75
|
+
if (!f.endsWith(".json")) continue;
|
|
76
|
+
const abs = ABS("data/" + f);
|
|
77
|
+
const st = fs.lstatSync(abs);
|
|
78
|
+
if (st.isSymbolicLink()) {
|
|
79
|
+
console.error(
|
|
80
|
+
`[validate-indexes] data/${f} is a symbolic link — refusing to follow. ` +
|
|
81
|
+
`Replace with the real file or remove the entry.`
|
|
82
|
+
);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
liveSources.add("data/" + f);
|
|
87
|
+
}
|
|
88
|
+
for (const s of manifest.skills) liveSources.add(s.path);
|
|
53
89
|
|
|
54
|
-
const drift = [];
|
|
55
|
-
const missing = [];
|
|
56
|
-
const recordedKeys = new Set(Object.keys(recorded));
|
|
90
|
+
const drift = [];
|
|
91
|
+
const missing = [];
|
|
92
|
+
const recordedKeys = new Set(Object.keys(recorded));
|
|
57
93
|
|
|
58
|
-
for (const p of liveSources) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
94
|
+
for (const p of liveSources) {
|
|
95
|
+
if (!recordedKeys.has(p)) {
|
|
96
|
+
missing.push(`new source not in index: ${p}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const abs = ABS(p);
|
|
100
|
+
// F16 — also check listed-but-symlinked sources. lstatSync on a missing
|
|
101
|
+
// file throws; mirror the existsSync semantics by guarding it.
|
|
102
|
+
if (fs.existsSync(abs)) {
|
|
103
|
+
const st = fs.lstatSync(abs);
|
|
104
|
+
if (st.isSymbolicLink()) {
|
|
105
|
+
missing.push(`source ${p} is a symbolic link — refusing to follow`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const live = sha256(fs.readFileSync(abs));
|
|
110
|
+
if (live !== recorded[p]) {
|
|
111
|
+
drift.push(`hash drift: ${p} (recorded ${recorded[p].slice(0, 12)}…, live ${live.slice(0, 12)}…)`);
|
|
112
|
+
}
|
|
62
113
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
114
|
+
for (const p of recordedKeys) {
|
|
115
|
+
if (!liveSources.has(p)) {
|
|
116
|
+
missing.push(`stale source in index (file removed): ${p}`);
|
|
117
|
+
}
|
|
66
118
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
119
|
+
|
|
120
|
+
const issues = [...drift, ...missing];
|
|
121
|
+
if (issues.length === 0) {
|
|
122
|
+
console.log(`[validate-indexes] indexes current — ${recordedKeys.size} sources hashed at ${meta.generated_at}.`);
|
|
123
|
+
return;
|
|
71
124
|
}
|
|
72
|
-
}
|
|
73
125
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
console.
|
|
77
|
-
process.
|
|
126
|
+
console.error("[validate-indexes] indexes STALE:");
|
|
127
|
+
for (const i of issues) console.error(" • " + i);
|
|
128
|
+
console.error("[validate-indexes] regenerate with: npm run build-indexes");
|
|
129
|
+
process.exitCode = 1;
|
|
78
130
|
}
|
|
79
131
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
process.exit(1);
|
|
132
|
+
if (require.main === module) main();
|
|
133
|
+
|
|
134
|
+
module.exports = { main };
|