@blamejs/exceptd-skills 0.12.41 → 0.13.0
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 +91 -0
- package/bin/exceptd.js +52 -44
- package/data/_indexes/_meta.json +47 -47
- package/data/_indexes/chains.json +485 -13
- package/data/_indexes/jurisdiction-map.json +15 -4
- package/data/_indexes/section-offsets.json +1244 -1244
- package/data/_indexes/token-budget.json +173 -173
- package/data/atlas-ttps.json +54 -11
- package/data/attack-techniques.json +113 -17
- package/data/cve-catalog.json +17 -24
- package/data/cwe-catalog.json +8 -2
- package/data/framework-control-gaps.json +13 -3
- package/data/playbooks/ai-api.json +5 -0
- package/data/playbooks/cicd-pipeline-compromise.json +970 -0
- package/data/playbooks/cloud-iam-incident.json +4 -1
- package/data/playbooks/cred-stores.json +10 -0
- package/data/playbooks/framework.json +16 -0
- package/data/playbooks/hardening.json +4 -0
- package/data/playbooks/identity-sso-compromise.json +951 -0
- package/data/playbooks/idp-incident.json +3 -0
- package/data/playbooks/kernel.json +6 -0
- package/data/playbooks/llm-tool-use-exfil.json +963 -0
- package/data/playbooks/mcp.json +6 -0
- package/data/playbooks/runtime.json +4 -0
- package/data/playbooks/sbom.json +13 -0
- package/data/playbooks/secrets.json +6 -0
- package/data/playbooks/webhook-callback-abuse.json +916 -0
- package/lib/cross-ref-api.js +33 -13
- package/lib/cve-curation.js +12 -1
- package/lib/exit-codes.js +29 -0
- package/lib/lint-skills.js +24 -2
- package/lib/refresh-external.js +10 -1
- package/lib/scoring.js +55 -0
- package/manifest.json +83 -83
- package/orchestrator/index.js +32 -24
- package/package.json +1 -1
- package/sbom.cdx.json +122 -78
- package/scripts/predeploy.js +7 -13
- package/scripts/refresh-reverse-refs.js +86 -0
- package/scripts/refresh-sbom.js +21 -4
- package/skills/age-gates-child-safety/skill.md +1 -5
- package/skills/ai-attack-surface/skill.md +11 -4
- package/skills/ai-c2-detection/skill.md +11 -2
- package/skills/ai-risk-management/skill.md +4 -2
- package/skills/api-security/skill.md +7 -8
- package/skills/attack-surface-pentest/skill.md +2 -2
- package/skills/cloud-iam-incident/skill.md +1 -5
- package/skills/cloud-security/skill.md +0 -4
- package/skills/compliance-theater/skill.md +10 -2
- package/skills/container-runtime-security/skill.md +1 -3
- package/skills/dlp-gap-analysis/skill.md +3 -4
- package/skills/email-security-anti-phishing/skill.md +1 -8
- package/skills/exploit-scoring/skill.md +7 -2
- package/skills/framework-gap-analysis/skill.md +1 -1
- package/skills/fuzz-testing-strategy/skill.md +1 -2
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +1 -3
- package/skills/idp-incident-response/skill.md +1 -4
- package/skills/incident-response-playbook/skill.md +1 -5
- package/skills/kernel-lpe-triage/skill.md +2 -2
- package/skills/mcp-agent-trust/skill.md +13 -3
- package/skills/mlops-security/skill.md +2 -3
- package/skills/ot-ics-security/skill.md +0 -3
- package/skills/policy-exception-gen/skill.md +11 -3
- package/skills/pqc-first/skill.md +4 -2
- package/skills/rag-pipeline-security/skill.md +2 -0
- package/skills/ransomware-response/skill.md +1 -5
- package/skills/researcher/skill.md +4 -3
- package/skills/sector-energy/skill.md +0 -4
- package/skills/sector-federal-government/skill.md +2 -3
- package/skills/sector-financial/skill.md +1 -4
- package/skills/sector-healthcare/skill.md +0 -5
- package/skills/sector-telecom/skill.md +0 -4
- package/skills/security-maturity-tiers/skill.md +1 -2
- package/skills/skill-update-loop/skill.md +4 -3
- package/skills/supply-chain-integrity/skill.md +4 -3
- package/skills/threat-model-currency/skill.md +1 -1
- package/skills/threat-modeling-methodology/skill.md +2 -1
- package/skills/webapp-security/skill.md +0 -5
package/lib/cross-ref-api.js
CHANGED
|
@@ -37,56 +37,76 @@ const _cache = new Map();
|
|
|
37
37
|
// can inspect.
|
|
38
38
|
const _loadErrors = [];
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/**
|
|
41
|
+
* v0.13.0: cache invalidation is keyed on (mtimeMs, size). Pre-v0.13 it
|
|
42
|
+
* was mtime-only, but on filesystems with 1-2s mtime granularity
|
|
43
|
+
* (FAT32, HFS+ pre-APFS, NFSv3, Docker bind-mounts that proxy mtime)
|
|
44
|
+
* a rapid refresh-then-reload within the same second served stale
|
|
45
|
+
* cached data. Adding `size` catches every content change that affects
|
|
46
|
+
* byte count; mtimeMs catches in-place rewrites that preserve byte
|
|
47
|
+
* count. Together they cover every realistic catalog-mutation path
|
|
48
|
+
* without the cost of a per-load SHA computation. SHA-based tier is
|
|
49
|
+
* available via _statContentHash() when callers want full invalidation
|
|
50
|
+
* (e.g. long-running daemons against append-only catalogs).
|
|
51
|
+
*/
|
|
52
|
+
function _statSignature(p) {
|
|
53
|
+
try {
|
|
54
|
+
const s = fs.statSync(p);
|
|
55
|
+
return { mtime: s.mtimeMs, size: s.size };
|
|
56
|
+
} catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _signatureEquals(a, b) {
|
|
60
|
+
if (a === null && b === null) return true;
|
|
61
|
+
if (a === null || b === null) return false;
|
|
62
|
+
return a.mtime === b.mtime && a.size === b.size;
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
function loadCatalog(filename) {
|
|
46
66
|
const full = path.join(DATA_DIR, filename);
|
|
47
|
-
const
|
|
67
|
+
const sig = _statSignature(full);
|
|
48
68
|
const cached = _cache.get(filename);
|
|
49
|
-
if (cached && (
|
|
69
|
+
if (cached && (sig === null || _signatureEquals(cached.sig, sig))) {
|
|
50
70
|
return cached.value;
|
|
51
71
|
}
|
|
52
72
|
if (!fs.existsSync(full)) {
|
|
53
|
-
_cache.set(filename, { value: {},
|
|
73
|
+
_cache.set(filename, { value: {}, sig });
|
|
54
74
|
return {};
|
|
55
75
|
}
|
|
56
76
|
try {
|
|
57
77
|
const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
58
|
-
_cache.set(filename, { value: parsed,
|
|
78
|
+
_cache.set(filename, { value: parsed, sig });
|
|
59
79
|
return parsed;
|
|
60
80
|
} catch (e) {
|
|
61
81
|
_loadErrors.push({ kind: 'catalog', file: filename, error: e.message });
|
|
62
82
|
const stub = {};
|
|
63
83
|
Object.defineProperty(stub, '_loadError', { value: e.message, enumerable: false });
|
|
64
|
-
_cache.set(filename, { value: stub,
|
|
84
|
+
_cache.set(filename, { value: stub, sig });
|
|
65
85
|
return stub;
|
|
66
86
|
}
|
|
67
87
|
}
|
|
68
88
|
|
|
69
89
|
function loadIndex(filename) {
|
|
70
90
|
const full = path.join(INDEX_DIR, filename);
|
|
71
|
-
const
|
|
91
|
+
const sig = _statSignature(full);
|
|
72
92
|
const key = 'idx:' + filename;
|
|
73
93
|
const cached = _cache.get(key);
|
|
74
|
-
if (cached && (
|
|
94
|
+
if (cached && (sig === null || _signatureEquals(cached.sig, sig))) {
|
|
75
95
|
return cached.value;
|
|
76
96
|
}
|
|
77
97
|
if (!fs.existsSync(full)) {
|
|
78
|
-
_cache.set(key, { value: {},
|
|
98
|
+
_cache.set(key, { value: {}, sig });
|
|
79
99
|
return {};
|
|
80
100
|
}
|
|
81
101
|
try {
|
|
82
102
|
const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
83
|
-
_cache.set(key, { value: parsed,
|
|
103
|
+
_cache.set(key, { value: parsed, sig });
|
|
84
104
|
return parsed;
|
|
85
105
|
} catch (e) {
|
|
86
106
|
_loadErrors.push({ kind: 'index', file: filename, error: e.message });
|
|
87
107
|
const stub = {};
|
|
88
108
|
Object.defineProperty(stub, '_loadError', { value: e.message, enumerable: false });
|
|
89
|
-
_cache.set(key, { value: stub,
|
|
109
|
+
_cache.set(key, { value: stub, sig });
|
|
90
110
|
return stub;
|
|
91
111
|
}
|
|
92
112
|
}
|
package/lib/cve-curation.js
CHANGED
|
@@ -637,7 +637,18 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
637
637
|
|
|
638
638
|
function writeJsonAtomic(p, obj) {
|
|
639
639
|
const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
640
|
-
|
|
640
|
+
// v0.13.0: fsync the tmp file before rename so a power loss between
|
|
641
|
+
// write and rename leaves the durable destination intact. Without
|
|
642
|
+
// fsync the data sits in the OS page cache and the rename succeeds
|
|
643
|
+
// atomically, but the renamed file may be zero-length / partial on
|
|
644
|
+
// crash. Open + write + fsync + close + rename is the durable idiom.
|
|
645
|
+
const fd = fs.openSync(tmpPath, 'w');
|
|
646
|
+
try {
|
|
647
|
+
fs.writeSync(fd, JSON.stringify(obj, null, 2) + "\n", 0, "utf8");
|
|
648
|
+
fs.fsyncSync(fd);
|
|
649
|
+
} finally {
|
|
650
|
+
fs.closeSync(fd);
|
|
651
|
+
}
|
|
641
652
|
try {
|
|
642
653
|
fs.renameSync(tmpPath, p);
|
|
643
654
|
} catch (err) {
|
package/lib/exit-codes.js
CHANGED
|
@@ -66,9 +66,38 @@ function listExitCodes() {
|
|
|
66
66
|
}));
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Set the process exit code WITHOUT calling process.exit(). Returns to the
|
|
71
|
+
* caller so any pending stdout/stderr writes drain on natural event-loop
|
|
72
|
+
* shutdown.
|
|
73
|
+
*
|
|
74
|
+
* `process.exit(N)` terminates the process synchronously, which truncates
|
|
75
|
+
* buffered async stdout when stdout is piped (CI, test harnesses, --json
|
|
76
|
+
* consumers). The v0.11.10 CI #100 fix established the exitCode-then-return
|
|
77
|
+
* idiom for that reason; every subsequent regression that re-introduced
|
|
78
|
+
* bare process.exit() in the dispatch surface was the same class of bug.
|
|
79
|
+
*
|
|
80
|
+
* Callers SHOULD prefer this helper for any exit-after-stdout-write path.
|
|
81
|
+
* Long-running daemons / tests that need synchronous termination can still
|
|
82
|
+
* use process.exit() directly — that's intentional and not what this guards.
|
|
83
|
+
*
|
|
84
|
+
* @param {number} code Exit code (use the EXIT_CODES constants)
|
|
85
|
+
* @returns {void}
|
|
86
|
+
*/
|
|
87
|
+
function safeExit(code) {
|
|
88
|
+
// Only override exitCode when it isn't already set to a non-zero value —
|
|
89
|
+
// matches the emit() ok:false fallback contract so a caller that already
|
|
90
|
+
// set BLOCKED (4) before emit() doesn't get overwritten by a later
|
|
91
|
+
// GENERIC_FAILURE (1).
|
|
92
|
+
if (!process.exitCode || process.exitCode === 0) {
|
|
93
|
+
process.exitCode = code;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
module.exports = {
|
|
70
98
|
EXIT_CODES,
|
|
71
99
|
EXIT_CODE_DESCRIPTIONS,
|
|
72
100
|
exitCodeName,
|
|
73
101
|
listExitCodes,
|
|
102
|
+
safeExit,
|
|
74
103
|
};
|
package/lib/lint-skills.js
CHANGED
|
@@ -243,6 +243,11 @@ function extractFrontmatterBlock(content) {
|
|
|
243
243
|
/* Validate frontmatter object against the codified schema rules. */
|
|
244
244
|
function validateFrontmatter(fm, skillName) {
|
|
245
245
|
const errors = [];
|
|
246
|
+
// v0.13.0: validateFrontmatter now ALSO surfaces warnings (e.g. the
|
|
247
|
+
// last_threat_review 180-day soft cap). Return signature changes from
|
|
248
|
+
// `string[]` to `{ errors: string[], warnings: string[] }` — callers
|
|
249
|
+
// updated accordingly.
|
|
250
|
+
const warnings = [];
|
|
246
251
|
|
|
247
252
|
for (const key of Object.keys(fm)) {
|
|
248
253
|
if (!ALL_KNOWN_FIELDS.has(key)) {
|
|
@@ -351,10 +356,25 @@ function validateFrontmatter(fm, skillName) {
|
|
|
351
356
|
errors.push(
|
|
352
357
|
`frontmatter.last_threat_review "${fm.last_threat_review}" is not an ISO date (YYYY-MM-DD)`,
|
|
353
358
|
);
|
|
359
|
+
} else {
|
|
360
|
+
// v0.13.0: Hard Rule #8 forcing function — refuse skills whose
|
|
361
|
+
// last_threat_review is older than the staleness threshold.
|
|
362
|
+
// 180-day soft cap (warn), 365-day hard cap (fail). Operators on
|
|
363
|
+
// older releases who don't refresh fall off the supported window.
|
|
364
|
+
const days = Math.floor((Date.now() - Date.parse(fm.last_threat_review + 'T00:00:00Z')) / (24 * 60 * 60 * 1000));
|
|
365
|
+
if (days > 365) {
|
|
366
|
+
errors.push(
|
|
367
|
+
`frontmatter.last_threat_review "${fm.last_threat_review}" is ${days} days old — Hard Rule #8 staleness gate (hard fail at >365 days). Refresh the threat review against current intel and bump the date.`,
|
|
368
|
+
);
|
|
369
|
+
} else if (days > 180) {
|
|
370
|
+
warnings.push(
|
|
371
|
+
`frontmatter.last_threat_review "${fm.last_threat_review}" is ${days} days old — Hard Rule #8 staleness warning (warn at >180 days, hard fail at >365). Schedule a review.`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
354
374
|
}
|
|
355
375
|
}
|
|
356
376
|
|
|
357
|
-
return errors;
|
|
377
|
+
return { errors, warnings };
|
|
358
378
|
}
|
|
359
379
|
|
|
360
380
|
/* L1 — Heading-anchored section detection.
|
|
@@ -460,7 +480,9 @@ function lintSkill(entry, ctx) {
|
|
|
460
480
|
return { name: entry.name, errors: skillErrors, warnings: skillWarnings };
|
|
461
481
|
}
|
|
462
482
|
|
|
463
|
-
|
|
483
|
+
const fmResult = validateFrontmatter(fm, entry.name);
|
|
484
|
+
skillErrors.push(...fmResult.errors);
|
|
485
|
+
skillWarnings.push(...fmResult.warnings);
|
|
464
486
|
|
|
465
487
|
if (Array.isArray(fm.data_deps)) {
|
|
466
488
|
for (const dep of fm.data_deps) {
|
package/lib/refresh-external.js
CHANGED
|
@@ -1066,7 +1066,16 @@ function loadCtx(opts) {
|
|
|
1066
1066
|
// worker threads) never collide on the same scratch path.
|
|
1067
1067
|
function writeJsonAtomic(p, obj) {
|
|
1068
1068
|
const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
1069
|
-
|
|
1069
|
+
// v0.13.0: fsync the tmp file before rename so a power loss between
|
|
1070
|
+
// write and rename leaves the durable destination intact. See the
|
|
1071
|
+
// matching helper in lib/cve-curation.js for the rationale.
|
|
1072
|
+
const fd = fs.openSync(tmpPath, 'w');
|
|
1073
|
+
try {
|
|
1074
|
+
fs.writeSync(fd, JSON.stringify(obj, null, 2) + "\n", 0, "utf8");
|
|
1075
|
+
fs.fsyncSync(fd);
|
|
1076
|
+
} finally {
|
|
1077
|
+
fs.closeSync(fd);
|
|
1078
|
+
}
|
|
1070
1079
|
try {
|
|
1071
1080
|
fs.renameSync(tmpPath, p);
|
|
1072
1081
|
} catch (err) {
|
package/lib/scoring.js
CHANGED
|
@@ -346,6 +346,54 @@ function compare(cveId, catalog, opts) {
|
|
|
346
346
|
return out;
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
/**
|
|
350
|
+
* v0.13.0: detect rwep_factors shape. The catalog historically stored
|
|
351
|
+
* factors in two distinct shapes that look identical at the field level:
|
|
352
|
+
*
|
|
353
|
+
* Shape A (raw): `{ cisa_kev: true, blast_radius: 30, ... }`
|
|
354
|
+
* - booleans + integers in their natural form
|
|
355
|
+
* - score derives from `scoreCustom(factors)` which applies weights
|
|
356
|
+
*
|
|
357
|
+
* Shape B (post-weight): `{ cisa_kev: 25, blast_radius: 30, ... }`
|
|
358
|
+
* - integers in their post-weight contribution (cisa_kev: 25 not true)
|
|
359
|
+
* - score = sum of values; no second weight pass
|
|
360
|
+
*
|
|
361
|
+
* Mixing shapes inside ONE entry silently breaks the sum invariant —
|
|
362
|
+
* a CVE with `cisa_kev: true, blast_radius: 30` reports rwep 30 (just
|
|
363
|
+
* blast_radius summed) when the operator-intended score is 55 (KEV + br).
|
|
364
|
+
* Until v0.13 nothing caught this; v0.13 adds shape detection that fires
|
|
365
|
+
* an error when the entry mixes booleans with non-trivial numeric weights.
|
|
366
|
+
*
|
|
367
|
+
* Returns 'A' for raw, 'B' for post-weight, 'unknown' for empty/edge
|
|
368
|
+
* cases, or 'mixed' for the violating case.
|
|
369
|
+
*/
|
|
370
|
+
function detectFactorShape(factors) {
|
|
371
|
+
if (!factors || typeof factors !== 'object') return 'unknown';
|
|
372
|
+
const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weaponization', 'ai_discovered', 'active_exploitation', 'patch_available', 'live_patch_available', 'patch_required_reboot'];
|
|
373
|
+
let sawBool = false;
|
|
374
|
+
let sawWeightedInt = false;
|
|
375
|
+
for (const [k, v] of Object.entries(factors)) {
|
|
376
|
+
if (k === 'blast_radius') continue; // always integer in both shapes
|
|
377
|
+
if (typeof v === 'boolean' || v === null) {
|
|
378
|
+
sawBool = true;
|
|
379
|
+
} else if (typeof v === 'number' && Math.abs(v) >= 5 && boolFields.includes(k)) {
|
|
380
|
+
// Field that's nominally boolean carrying a numeric weight (e.g. 25,
|
|
381
|
+
// 20, 15) — Shape B signature.
|
|
382
|
+
sawWeightedInt = true;
|
|
383
|
+
} else if (typeof v === 'number' && (v === 0 || v === 1) && boolFields.includes(k)) {
|
|
384
|
+
// 0/1 on a boolean-named field could be either shape; ambiguous, ignore.
|
|
385
|
+
continue;
|
|
386
|
+
} else if (typeof v === 'string' && boolFields.includes(k)) {
|
|
387
|
+
// String values (e.g. active_exploitation: 'confirmed') are Shape A.
|
|
388
|
+
sawBool = true;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (sawBool && sawWeightedInt) return 'mixed';
|
|
392
|
+
if (sawWeightedInt) return 'B';
|
|
393
|
+
if (sawBool) return 'A';
|
|
394
|
+
return 'unknown';
|
|
395
|
+
}
|
|
396
|
+
|
|
349
397
|
function validate(catalog) {
|
|
350
398
|
const errors = [];
|
|
351
399
|
for (const [cveId, entry] of Object.entries(catalog)) {
|
|
@@ -371,6 +419,13 @@ function validate(catalog) {
|
|
|
371
419
|
if (entry.live_patch_available && (!entry.live_patch_tools || entry.live_patch_tools.length === 0)) {
|
|
372
420
|
errors.push(`${cveId}: live_patch_available=true but live_patch_tools is empty`);
|
|
373
421
|
}
|
|
422
|
+
// v0.13.0: detect Shape A / Shape B / mixed factor shape. A 'mixed'
|
|
423
|
+
// shape would silently break the sum invariant; refuse it. See
|
|
424
|
+
// detectFactorShape() doc above for the failure mode.
|
|
425
|
+
const shape = detectFactorShape(entry.rwep_factors);
|
|
426
|
+
if (shape === 'mixed') {
|
|
427
|
+
errors.push(`${cveId}: rwep_factors mixes Shape A (booleans) with Shape B (post-weight integers) — sum invariant cannot hold. Convert factors to a single shape.`);
|
|
428
|
+
}
|
|
374
429
|
const calculatedRwep = scoreCustom({
|
|
375
430
|
cisa_kev: entry.cisa_kev,
|
|
376
431
|
poc_available: entry.poc_available,
|