@blamejs/exceptd-skills 0.12.40 → 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/AGENTS.md +17 -0
- package/ARCHITECTURE.md +7 -4
- package/CHANGELOG.md +215 -248
- package/CONTEXT.md +2 -2
- package/README.md +2 -8
- package/agents/threat-researcher.md +2 -2
- package/bin/exceptd.js +179 -81
- package/data/_indexes/_meta.json +50 -50
- package/data/_indexes/activity-feed.json +1 -1
- package/data/_indexes/catalog-summaries.json +1 -1
- package/data/_indexes/chains.json +485 -13
- package/data/_indexes/frequency.json +4 -0
- package/data/_indexes/jurisdiction-map.json +15 -4
- package/data/_indexes/section-offsets.json +1224 -1224
- package/data/_indexes/token-budget.json +170 -170
- package/data/atlas-ttps.json +54 -11
- package/data/attack-techniques.json +113 -17
- package/data/cve-catalog.json +38 -52
- package/data/cwe-catalog.json +8 -2
- package/data/exploit-availability.json +1 -0
- package/data/framework-control-gaps.json +149 -6
- package/data/global-frameworks.json +1 -0
- 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/crypto-codebase.json +13 -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/data/zeroday-lessons.json +1 -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 +25 -3
- package/lib/playbook-runner.js +8 -4
- package/lib/refresh-external.js +10 -1
- package/lib/scoring.js +64 -1
- package/lib/sign.js +40 -7
- package/lib/verify.js +5 -5
- package/manifest.json +83 -83
- package/orchestrator/README.md +7 -7
- package/orchestrator/index.js +46 -25
- package/orchestrator/scheduler.js +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +135 -91
- package/scripts/check-test-coverage.js +6 -6
- package/scripts/predeploy.js +7 -13
- package/scripts/refresh-reverse-refs.js +107 -20
- 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 +3 -4
- 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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"_meta": {
|
|
3
3
|
"schema_version": "1.1.0",
|
|
4
4
|
"last_updated": "2026-05-15",
|
|
5
|
+
"last_threat_review": "2026-05-17",
|
|
5
6
|
"purpose": "Zero-day learning loop output. Each entry maps a CVE to: attack vector, defense chain analysis, framework coverage, new control requirements generated, and exposure scoring. v1.1.0 (2026-05-15): every entry now carries ai_discovered_zeroday boolean + ai_discovery_source enum + ai_discovery_date + ai_assist_factor ladder, per AGENTS.md Hard Rule #7.",
|
|
6
7
|
"note": "Never delete entries. Closed gaps are marked status: closed. History is data.",
|
|
7
8
|
"tlp": "CLEAR",
|
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) {
|
|
@@ -759,7 +781,7 @@ function main() {
|
|
|
759
781
|
for (const o of orphans) {
|
|
760
782
|
console.log(`FAIL <orphan>`);
|
|
761
783
|
console.log(` - skill.md exists on disk but not in manifest: ${o}`);
|
|
762
|
-
console.log(` fix: re-run \`node lib/sign.js sign-all\` after adding it to manifest.json, OR delete the orphan directory`);
|
|
784
|
+
console.log(` fix: re-run sign-all (\`node $(exceptd path)/lib/sign.js sign-all\` from a contributor checkout) after adding it to manifest.json, OR delete the orphan directory`);
|
|
763
785
|
}
|
|
764
786
|
// P4 — air-gap completeness lint over data/playbooks/*.json.
|
|
765
787
|
airGapWarnings = lintPlaybookAirGap();
|
package/lib/playbook-runner.js
CHANGED
|
@@ -1679,7 +1679,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1679
1679
|
evidencePackage.signature_algorithm = 'HMAC-SHA256-session-key';
|
|
1680
1680
|
} else if (evidencePackage && evidencePackage.signed) {
|
|
1681
1681
|
evidencePackage.signature = null;
|
|
1682
|
-
evidencePackage.signature_pending = 'No session_key provided. Sign with Ed25519 via `node lib/sign.js sign-evidence <bundle.json>` post-emit.';
|
|
1682
|
+
evidencePackage.signature_pending = 'No session_key provided. Sign with Ed25519 via `node $(exceptd path)/lib/sign.js sign-evidence <bundle.json>` post-emit (contributor checkout) or `exceptd doctor --fix` to enable signing.';
|
|
1683
1683
|
}
|
|
1684
1684
|
|
|
1685
1685
|
// learning_loop lesson
|
|
@@ -2830,13 +2830,17 @@ function normalizeSubmission(submission, playbook) {
|
|
|
2830
2830
|
// which confuses detect()'s indicator-id lookup. Strip and log instead.
|
|
2831
2831
|
if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
|
|
2832
2832
|
&& (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
|
|
2833
|
-
|
|
2834
|
-
|
|
2833
|
+
// Clone before mutating _runErrors so a frozen / shared input
|
|
2834
|
+
// submission isn't modified in place. Pre-fix a caller passing a
|
|
2835
|
+
// frozen submission (Object.freeze for safety, or a shared reference
|
|
2836
|
+
// across parallel runs) threw uncaught on the _runErrors push.
|
|
2837
|
+
const carry = Array.isArray(submission._runErrors) ? submission._runErrors.slice() : [];
|
|
2838
|
+
pushRunError(carry, {
|
|
2835
2839
|
kind: 'signal_overrides_invalid',
|
|
2836
2840
|
supplied_type: Array.isArray(submission.signal_overrides) ? 'array' : typeof submission.signal_overrides,
|
|
2837
2841
|
reason: 'signal_overrides must be a plain object mapping indicator-id → verdict.'
|
|
2838
2842
|
}, { dedupeKey: e => String(e.supplied_type) });
|
|
2839
|
-
submission = { ...submission, signal_overrides: {} };
|
|
2843
|
+
submission = { ...submission, signal_overrides: {}, _runErrors: carry };
|
|
2840
2844
|
}
|
|
2841
2845
|
|
|
2842
2846
|
// v0.11.3 #71 fix: the CLI may inject `signals._bundle_formats` before
|
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
|
@@ -300,7 +300,15 @@ function compare(cveId, catalog, opts) {
|
|
|
300
300
|
// SLA is insufficient. ±10 is the tightest classifier that still treats
|
|
301
301
|
// ordinary CVSS rounding noise as alignment.
|
|
302
302
|
let explanation = '';
|
|
303
|
-
|
|
303
|
+
// Surface the "no scoring signal" case distinctly from "broadly
|
|
304
|
+
// aligned". Pre-fix a CVE with rwep_score: 0 AND cvss_score: 0 (e.g.
|
|
305
|
+
// catalog entry created before scoring backfill) printed "broadly
|
|
306
|
+
// aligned" — coincidence-passing per the field-present-not-populated
|
|
307
|
+
// pitfall. Now the operator sees a specific signal pointing at the
|
|
308
|
+
// catalog gap rather than a false sense of alignment.
|
|
309
|
+
if ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
|
|
310
|
+
explanation = 'No scoring signal — both RWEP and CVSS are zero/null. Investigate the catalog entry; this CVE has no usable risk score.';
|
|
311
|
+
} else if (delta > 10) {
|
|
304
312
|
explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
|
|
305
313
|
const driving = [];
|
|
306
314
|
if (entry.cisa_kev) driving.push('CISA KEV (+25)');
|
|
@@ -338,6 +346,54 @@ function compare(cveId, catalog, opts) {
|
|
|
338
346
|
return out;
|
|
339
347
|
}
|
|
340
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
|
+
|
|
341
397
|
function validate(catalog) {
|
|
342
398
|
const errors = [];
|
|
343
399
|
for (const [cveId, entry] of Object.entries(catalog)) {
|
|
@@ -363,6 +419,13 @@ function validate(catalog) {
|
|
|
363
419
|
if (entry.live_patch_available && (!entry.live_patch_tools || entry.live_patch_tools.length === 0)) {
|
|
364
420
|
errors.push(`${cveId}: live_patch_available=true but live_patch_tools is empty`);
|
|
365
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
|
+
}
|
|
366
429
|
const calculatedRwep = scoreCustom({
|
|
367
430
|
cisa_kev: entry.cisa_kev,
|
|
368
431
|
poc_available: entry.poc_available,
|
package/lib/sign.js
CHANGED
|
@@ -101,6 +101,22 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
101
101
|
process.exit(1);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// Refuse to silently overwrite an existing public key when no private key
|
|
105
|
+
// is present. This is the v0.11.x signature-regression class: a host with
|
|
106
|
+
// a working pubkey but missing privkey running generate-keypair would
|
|
107
|
+
// produce a fresh pubkey divergent from every shipped signature. Operators
|
|
108
|
+
// running `exceptd doctor --fix` on a stock install would replace the
|
|
109
|
+
// shipped keys/public.pem with one whose private half exists only on
|
|
110
|
+
// their machine — every subsequent verify against shipped signatures fails.
|
|
111
|
+
// Force the operator to be explicit via --rotate (which signals intent to
|
|
112
|
+
// re-sign).
|
|
113
|
+
if (fs.existsSync(PUBLIC_KEY_PATH) && !rotate) {
|
|
114
|
+
console.error('[sign] Public key already exists at keys/public.pem but no matching private key.');
|
|
115
|
+
console.error('[sign] Refusing to overwrite the public key — that would orphan every existing signature.');
|
|
116
|
+
console.error('[sign] If you are setting up a fresh signing identity, pass --rotate to confirm. After --rotate you must re-sign all skills with sign-all.');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
104
120
|
fs.mkdirSync(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
105
121
|
fs.mkdirSync(PUBLIC_KEYS_DIR, { recursive: true });
|
|
106
122
|
|
|
@@ -115,20 +131,35 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
115
131
|
// on win32, fs.writeFileSync `mode` does not produce
|
|
116
132
|
// a POSIX-style restrictive ACL. Tighten via icacls so other desktop
|
|
117
133
|
// users on the same workstation / CI runner can't read the key.
|
|
118
|
-
restrictWindowsAcl(PRIVATE_KEY_PATH);
|
|
134
|
+
const aclHardened = restrictWindowsAcl(PRIVATE_KEY_PATH);
|
|
119
135
|
|
|
120
136
|
if (rotate) {
|
|
121
|
-
console.log('[sign] Keypair rotated. All existing signatures are now invalid —
|
|
137
|
+
console.log('[sign] Keypair rotated. All existing signatures are now invalid — re-sign with sign-all.');
|
|
122
138
|
} else {
|
|
123
139
|
console.log('[sign] Ed25519 keypair generated.');
|
|
124
140
|
console.log(` Private key: .keys/private.pem (gitignored — do not commit)`);
|
|
125
141
|
console.log(` Public key: keys/public.pem (tracked — commit this)`);
|
|
126
142
|
}
|
|
143
|
+
if (process.platform === 'win32') {
|
|
144
|
+
console.log(` Windows ACL hardened: ${aclHardened ? 'yes' : 'NO — other desktop users on this machine may be able to read the private key'}`);
|
|
145
|
+
}
|
|
127
146
|
|
|
128
147
|
console.log('\nNext steps:');
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
if (rotate) {
|
|
149
|
+
// After --rotate the private key IS present, so `doctor --fix`'s
|
|
150
|
+
// missing-key path won't fire. Tell the operator to re-sign
|
|
151
|
+
// directly. (doctor --fix v0.12.41+ also detects this case and
|
|
152
|
+
// chains sign-all, so either path converges.)
|
|
153
|
+
console.log(' 1. exceptd doctor --fix — detects post-rotate stale signatures and chains sign-all');
|
|
154
|
+
console.log(' (or: node $(exceptd path)/lib/sign.js sign-all — re-sign directly)');
|
|
155
|
+
console.log(' 2. exceptd doctor — confirm signatures verify against the new public key');
|
|
156
|
+
console.log(' 3. git add keys/public.pem && git commit -m "rotate signing public key"');
|
|
157
|
+
} else {
|
|
158
|
+
console.log(' 1. exceptd doctor --fix — chains sign-all after first key generation');
|
|
159
|
+
console.log(' 2. exceptd doctor — confirm signatures verify');
|
|
160
|
+
console.log(' 3. git add keys/public.pem && git commit -m "add signing public key"');
|
|
161
|
+
}
|
|
162
|
+
return { aclHardened };
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
/**
|
|
@@ -380,11 +411,11 @@ function signCanonicalManifest(manifest, privateKey) {
|
|
|
380
411
|
* @param {string} targetPath absolute path of the private key file
|
|
381
412
|
*/
|
|
382
413
|
function restrictWindowsAcl(targetPath) {
|
|
383
|
-
if (process.platform !== 'win32') return;
|
|
414
|
+
if (process.platform !== 'win32') return true;
|
|
384
415
|
const user = process.env.USERNAME;
|
|
385
416
|
if (!user) {
|
|
386
417
|
console.warn('[sign] WARN: USERNAME env var not set — skipping Windows ACL hardening on ' + targetPath);
|
|
387
|
-
return;
|
|
418
|
+
return false;
|
|
388
419
|
}
|
|
389
420
|
try {
|
|
390
421
|
execFileSync('icacls', [
|
|
@@ -393,6 +424,7 @@ function restrictWindowsAcl(targetPath) {
|
|
|
393
424
|
'/grant:r',
|
|
394
425
|
`${user}:F`,
|
|
395
426
|
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
427
|
+
return true;
|
|
396
428
|
} catch (err) {
|
|
397
429
|
console.warn(
|
|
398
430
|
'[sign] WARN: icacls hardening failed on ' + targetPath + ': ' +
|
|
@@ -400,6 +432,7 @@ function restrictWindowsAcl(targetPath) {
|
|
|
400
432
|
' — the key was written but ACL inheritance was not stripped. ' +
|
|
401
433
|
'Other desktop users on this machine may be able to read it.'
|
|
402
434
|
);
|
|
435
|
+
return false;
|
|
403
436
|
}
|
|
404
437
|
}
|
|
405
438
|
|
package/lib/verify.js
CHANGED
|
@@ -87,7 +87,7 @@ const EXPECTED_FINGERPRINT_PATH = path.join(ROOT, 'keys', 'EXPECTED_FINGERPRINT'
|
|
|
87
87
|
function verifyAll() {
|
|
88
88
|
const publicKey = loadPublicKey();
|
|
89
89
|
if (!publicKey) {
|
|
90
|
-
console.error('[verify] No public key at keys/public.pem — run
|
|
90
|
+
console.error('[verify] No public key at keys/public.pem — run `exceptd doctor --fix` (or `node $(exceptd path)/lib/sign.js generate-keypair` from a contributor checkout)');
|
|
91
91
|
return { valid: [], invalid: [], missing_sig: [], missing_file: [], no_key: true };
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -128,7 +128,7 @@ function verifyOne(skillName) {
|
|
|
128
128
|
*/
|
|
129
129
|
function signAll() {
|
|
130
130
|
const privateKey = loadPrivateKey();
|
|
131
|
-
if (!privateKey) throw new Error('No private key at .keys/private.pem — run
|
|
131
|
+
if (!privateKey) throw new Error('No private key at .keys/private.pem — run `exceptd doctor --fix` (or `node $(exceptd path)/lib/sign.js generate-keypair` from a contributor checkout)');
|
|
132
132
|
|
|
133
133
|
// P1-4: load the manifest without the signature gate. We're about to
|
|
134
134
|
// mutate the manifest (re-sign skills + re-sign the manifest itself),
|
|
@@ -236,7 +236,7 @@ function validateSkillPath(skillPath) {
|
|
|
236
236
|
|
|
237
237
|
function verifySkill(skill, publicKey) {
|
|
238
238
|
if (!skill.signature) {
|
|
239
|
-
return { status: 'missing_sig', reason: 'No Ed25519 signature in manifest — run
|
|
239
|
+
return { status: 'missing_sig', reason: 'No Ed25519 signature in manifest — run `exceptd doctor --fix` (or `node $(exceptd path)/lib/sign.js sign-all` from a contributor checkout)' };
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
const skillPath = path.join(ROOT, skill.path);
|
|
@@ -447,7 +447,7 @@ function loadManifestValidated() {
|
|
|
447
447
|
// console.warn would spam stderr per call. Node's emitWarning() with
|
|
448
448
|
// a stable `code` collapses repeated emissions automatically.
|
|
449
449
|
process.emitWarning(
|
|
450
|
-
'manifest.json has no top-level manifest_signature field. This tarball predates v0.12.17 manifest signing; skills will still be verified but a coordinated rewrite of manifest.json could go undetected. Re-run `node lib/sign.js sign-all` to add the signature.',
|
|
450
|
+
'manifest.json has no top-level manifest_signature field. This tarball predates v0.12.17 manifest signing; skills will still be verified but a coordinated rewrite of manifest.json could go undetected. Re-run `exceptd doctor --fix` (or `node $(exceptd path)/lib/sign.js sign-all` from a contributor checkout) to add the signature.',
|
|
451
451
|
{ code: 'EXCEPTD_MANIFEST_UNSIGNED' }
|
|
452
452
|
);
|
|
453
453
|
} else if (sigResult.status === 'no-key') {
|
|
@@ -693,7 +693,7 @@ if (require.main === module) {
|
|
|
693
693
|
if (arg === 'check-key') {
|
|
694
694
|
const pub = loadPublicKey();
|
|
695
695
|
if (!pub) {
|
|
696
|
-
console.error('[verify] No public key — run
|
|
696
|
+
console.error('[verify] No public key — run `exceptd doctor --fix` (or `node $(exceptd path)/lib/sign.js generate-keypair` from a contributor checkout)');
|
|
697
697
|
process.exit(1);
|
|
698
698
|
}
|
|
699
699
|
console.log('[verify] Public key present at keys/public.pem');
|