@blamejs/exceptd-skills 0.12.15 → 0.12.18
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 +136 -0
- package/bin/exceptd.js +395 -20
- package/data/_indexes/_meta.json +3 -3
- package/data/cve-catalog.json +1 -1
- package/data/playbooks/ai-api.json +27 -5
- package/data/playbooks/containers.json +34 -7
- package/data/playbooks/cred-stores.json +21 -4
- package/data/playbooks/crypto-codebase.json +29 -14
- package/data/playbooks/crypto.json +13 -3
- package/data/playbooks/framework.json +15 -3
- package/data/playbooks/hardening.json +24 -5
- package/data/playbooks/kernel.json +13 -3
- package/data/playbooks/library-author.json +21 -10
- package/data/playbooks/mcp.json +16 -4
- package/data/playbooks/runtime.json +20 -4
- package/data/playbooks/sbom.json +18 -5
- package/data/playbooks/secrets.json +33 -6
- package/lib/auto-discovery.js +70 -32
- package/lib/cve-curation.js +15 -9
- package/lib/prefetch.js +30 -8
- package/lib/refresh-network.js +40 -0
- package/lib/schemas/playbook.schema.json +7 -1
- package/lib/scoring.js +171 -11
- package/lib/sign.js +163 -2
- package/lib/validate-playbooks.js +46 -0
- package/lib/verify.js +149 -2
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +45 -40
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +35 -6
package/lib/scoring.js
CHANGED
|
@@ -3,6 +3,40 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* RWEP — Real-World Exploit Priority scoring engine
|
|
5
5
|
* Supplements CVSS with exploit availability, active exploitation, and operational constraints.
|
|
6
|
+
*
|
|
7
|
+
* ----------------------------------------------------------------------------
|
|
8
|
+
* `rwep_factors` dual-semantics (audit J F2)
|
|
9
|
+
* ----------------------------------------------------------------------------
|
|
10
|
+
* Catalog entries (data/cve-catalog.json) store `rwep_factors` as an object
|
|
11
|
+
* whose values are POST-WEIGHT CONTRIBUTIONS for boolean / ladder factors
|
|
12
|
+
* but the RAW BLAST RADIUS for `blast_radius`. The two shapes coexist because
|
|
13
|
+
* each surface has different requirements:
|
|
14
|
+
*
|
|
15
|
+
* cisa_kev: 0 OR +25 (post-weight contribution)
|
|
16
|
+
* poc_available: 0 OR +20 (post-weight contribution)
|
|
17
|
+
* ai_factor: 0 OR +15 (post-weight contribution)
|
|
18
|
+
* active_exploitation: 0 / 10 / 5 / 20 (post-weight contribution from ladder)
|
|
19
|
+
* blast_radius: 0..30 RAW (intentionally NOT post-weight —
|
|
20
|
+
* mirrors the weight ceiling so it
|
|
21
|
+
* reads as raw blast magnitude)
|
|
22
|
+
* patch_available: 0 OR -15 (post-weight contribution)
|
|
23
|
+
* live_patch_available: 0 OR -10 (post-weight contribution)
|
|
24
|
+
* reboot_required: 0 OR +5 (post-weight contribution)
|
|
25
|
+
*
|
|
26
|
+
* Operator-facing implication: summing `Object.values(rwep_factors)` produces
|
|
27
|
+
* the stored `rwep_score` for catalog entries because the blast weight is 30
|
|
28
|
+
* (matches the raw cap). This dual-shape is intentional but easy to misuse;
|
|
29
|
+
* direct boolean inputs should go through `scoreCustom()` instead.
|
|
30
|
+
*
|
|
31
|
+
* scoreCustom() input shape is DIFFERENT — it accepts BOOLEAN factors plus
|
|
32
|
+
* a numeric blast_radius and a string active_exploitation ladder value.
|
|
33
|
+
* `deriveRwepFromFactors()` is the shape-detecting bridge: if values look
|
|
34
|
+
* numeric (post-weighted), it sums; if values look boolean / string-ladder,
|
|
35
|
+
* it routes through scoreCustom.
|
|
36
|
+
*
|
|
37
|
+
* The semantic ambiguity is grandfathered. A clean rename (raw_factors vs
|
|
38
|
+
* weighted_contributions) is a minor-bump change and is deferred.
|
|
39
|
+
* ----------------------------------------------------------------------------
|
|
6
40
|
*/
|
|
7
41
|
|
|
8
42
|
const CVE_SCHEMA_REQUIRED = [
|
|
@@ -28,6 +62,29 @@ const RWEP_WEIGHTS = {
|
|
|
28
62
|
reboot_required: 5
|
|
29
63
|
};
|
|
30
64
|
|
|
65
|
+
// audit J F4: active_exploitation ladder. Aligned with playbook-runner's
|
|
66
|
+
// _activeExploitationLadder so the catalog scorer and the runtime evaluator
|
|
67
|
+
// produce identical results for the same string value. 'unknown' contributes
|
|
68
|
+
// a quarter of the confirmed weight (5 points) — operationally "we have not
|
|
69
|
+
// confirmed, but absence of evidence is not evidence of absence; do not
|
|
70
|
+
// score zero on a fresh CVE that hasn't been triaged yet".
|
|
71
|
+
const ACTIVE_EXPLOITATION_LADDER = {
|
|
72
|
+
confirmed: 1.0,
|
|
73
|
+
suspected: 0.5,
|
|
74
|
+
unknown: 0.25,
|
|
75
|
+
none: 0,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// The canonical set of factor keys scoreCustom recognises. Used by
|
|
79
|
+
// validateFactors to flag unknown keys (audit J F8).
|
|
80
|
+
const RECOGNISED_FACTOR_KEYS = new Set([
|
|
81
|
+
'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
82
|
+
'active_exploitation', 'blast_radius', 'patch_available',
|
|
83
|
+
'live_patch_available', 'reboot_required',
|
|
84
|
+
// accepted alias for the catalog field name
|
|
85
|
+
'patch_required_reboot',
|
|
86
|
+
]);
|
|
87
|
+
|
|
31
88
|
function score(cveId, catalog) {
|
|
32
89
|
const entry = catalog[cveId];
|
|
33
90
|
if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
|
|
@@ -68,13 +125,29 @@ function validateFactors(factors) {
|
|
|
68
125
|
} else if (!aeAllowed.includes(factors.active_exploitation)) {
|
|
69
126
|
warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
|
|
70
127
|
}
|
|
128
|
+
// audit J F6: NaN diagnostics. The prior message read "expected number,
|
|
129
|
+
// got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
|
|
130
|
+
// NaN === 'number'`. Number.isFinite catches NaN + Infinity + -Infinity
|
|
131
|
+
// and emits a useful message.
|
|
71
132
|
if (factors.blast_radius === undefined || factors.blast_radius === null) {
|
|
72
133
|
warnings.push('blast_radius: missing (treated as 0)');
|
|
73
|
-
} else if (typeof factors.blast_radius !== 'number'
|
|
134
|
+
} else if (typeof factors.blast_radius !== 'number') {
|
|
74
135
|
warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
|
|
136
|
+
} else if (Number.isNaN(factors.blast_radius)) {
|
|
137
|
+
warnings.push('blast_radius: NaN is not a valid numeric value (treated as 0)');
|
|
138
|
+
} else if (!Number.isFinite(factors.blast_radius)) {
|
|
139
|
+
warnings.push(`blast_radius: ${factors.blast_radius > 0 ? 'Infinity' : '-Infinity'} is not a finite numeric value (treated as 0)`);
|
|
75
140
|
} else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
|
|
76
141
|
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
142
|
}
|
|
143
|
+
// audit J F8: surface unknown factor keys so a typo'd answer file
|
|
144
|
+
// (`patch_avilable`, `cisa-kev`, etc.) doesn't silently default to false
|
|
145
|
+
// with no diagnostic.
|
|
146
|
+
for (const k of Object.keys(factors)) {
|
|
147
|
+
if (!RECOGNISED_FACTOR_KEYS.has(k)) {
|
|
148
|
+
warnings.push(`unknown factor: ${k} (ignored — not in the recognised key set)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
78
151
|
return warnings;
|
|
79
152
|
}
|
|
80
153
|
|
|
@@ -113,8 +186,15 @@ function scoreCustom(factors, opts) {
|
|
|
113
186
|
score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
|
|
114
187
|
score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
|
|
115
188
|
score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
|
|
116
|
-
|
|
117
|
-
|
|
189
|
+
// audit J F4 + F16: active_exploitation goes through the ladder rather
|
|
190
|
+
// than two hand-written branches with `Math.floor(weight/2)`. The floor
|
|
191
|
+
// was a no-op for even weights (20/2 = 10) but would have silently
|
|
192
|
+
// truncated to asymmetric results if a future operator bumped the
|
|
193
|
+
// weight to 21. The ladder + multiplication preserves the contribution
|
|
194
|
+
// exactly, including the new `unknown → 0.25 × weight = 5` mapping that
|
|
195
|
+
// aligns the catalog scorer with playbook-runner._activeExploitationLadder.
|
|
196
|
+
const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
|
|
197
|
+
score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
|
|
118
198
|
// v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
|
|
119
199
|
// NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
|
|
120
200
|
// check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
|
|
@@ -128,15 +208,60 @@ function scoreCustom(factors, opts) {
|
|
|
128
208
|
score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
|
|
129
209
|
score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
|
|
130
210
|
|
|
211
|
+
// audit J F10: keep the pre-clamp value so collectWarnings consumers can
|
|
212
|
+
// see deduction magnitude (e.g. a -25 raw score collapsed to 0 hides the
|
|
213
|
+
// fact that the entry had three mitigating factors).
|
|
214
|
+
const rawUnclamped = score;
|
|
215
|
+
|
|
131
216
|
// v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
|
|
132
217
|
// NaN production above (negative weight + Infinity + math edge case).
|
|
133
218
|
const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
|
|
134
219
|
if (opts && opts.collectWarnings) {
|
|
135
|
-
return {
|
|
220
|
+
return {
|
|
221
|
+
score: clamped,
|
|
222
|
+
_rwep_raw_unclamped: Number.isFinite(rawUnclamped) ? rawUnclamped : null,
|
|
223
|
+
_scoring_warnings: validateFactors(factors),
|
|
224
|
+
};
|
|
136
225
|
}
|
|
137
226
|
return clamped;
|
|
138
227
|
}
|
|
139
228
|
|
|
229
|
+
/**
|
|
230
|
+
* audit J F3 + audit M P1-C bridge: derive an RWEP score from a
|
|
231
|
+
* `rwep_factors` object regardless of which shape it uses.
|
|
232
|
+
*
|
|
233
|
+
* - SHAPE A (boolean / string-ladder): values are booleans + an
|
|
234
|
+
* active_exploitation string + a numeric blast_radius. Route through
|
|
235
|
+
* scoreCustom() — the canonical formula.
|
|
236
|
+
* - SHAPE B (catalog post-weight): values are numeric contributions
|
|
237
|
+
* (0 / ±N) plus a numeric blast_radius. Sum the numeric values and
|
|
238
|
+
* clamp to [0, 100]. This is how catalog `rwep_factors` are stored.
|
|
239
|
+
*
|
|
240
|
+
* Heuristic: if every value is a number, treat as Shape B (sum). If any
|
|
241
|
+
* value is boolean or a recognised ladder string, treat as Shape A
|
|
242
|
+
* (scoreCustom). This lets the curation apply-path and the auto-discovery
|
|
243
|
+
* builder share one canonical derivation that handles either operator
|
|
244
|
+
* input style without duplicating the scoring formula.
|
|
245
|
+
*/
|
|
246
|
+
function deriveRwepFromFactors(factors) {
|
|
247
|
+
if (!factors || typeof factors !== 'object') return 0;
|
|
248
|
+
const values = Object.values(factors);
|
|
249
|
+
if (values.length === 0) return 0;
|
|
250
|
+
const aeAllowed = new Set(['none', 'unknown', 'suspected', 'confirmed']);
|
|
251
|
+
const hasBooleanOrLadder = values.some(
|
|
252
|
+
(v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v)),
|
|
253
|
+
);
|
|
254
|
+
if (hasBooleanOrLadder) {
|
|
255
|
+
return scoreCustom(factors);
|
|
256
|
+
}
|
|
257
|
+
// Shape B: catalog post-weight. Sum + clamp.
|
|
258
|
+
let sum = 0;
|
|
259
|
+
for (const v of values) {
|
|
260
|
+
if (typeof v === 'number' && Number.isFinite(v)) sum += v;
|
|
261
|
+
}
|
|
262
|
+
return Math.max(0, Math.min(100, sum));
|
|
263
|
+
}
|
|
264
|
+
|
|
140
265
|
function timeline(rwepScore) {
|
|
141
266
|
if (rwepScore >= 90) return { hours: 4, label: 'Immediate — live patch or isolate within 4 hours' };
|
|
142
267
|
if (rwepScore >= 75) return { hours: 24, label: 'Urgent — patch or compensating controls within 24 hours' };
|
|
@@ -146,17 +271,36 @@ function timeline(rwepScore) {
|
|
|
146
271
|
return { hours: null, label: 'Low — next scheduled maintenance' };
|
|
147
272
|
}
|
|
148
273
|
|
|
149
|
-
function compare(cveId, catalog) {
|
|
274
|
+
function compare(cveId, catalog, opts) {
|
|
150
275
|
const entry = catalog[cveId];
|
|
151
276
|
if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
|
|
152
277
|
|
|
153
|
-
|
|
278
|
+
// audit J F11: `--recompute` ignores the stored rwep_score and forces a
|
|
279
|
+
// fresh computation from rwep_factors. Useful for catching catalog drift
|
|
280
|
+
// (stored score grew stale relative to current weights) and for auditing
|
|
281
|
+
// the divergence between stored vs. formula-derived scores.
|
|
282
|
+
const recompute = !!(opts && opts.recompute);
|
|
283
|
+
let rwep;
|
|
284
|
+
if (recompute) {
|
|
285
|
+
const factors = entry.rwep_factors || {};
|
|
286
|
+
// The catalog's rwep_factors shape is "post-weight" (Shape B). Route
|
|
287
|
+
// through the shape-detecting helper so a catalog whose factors were
|
|
288
|
+
// hand-edited in either shape still produces a usable score.
|
|
289
|
+
rwep = deriveRwepFromFactors(factors);
|
|
290
|
+
} else {
|
|
291
|
+
rwep = entry.rwep_score;
|
|
292
|
+
}
|
|
154
293
|
const cvss = entry.cvss_score;
|
|
155
294
|
const cvssEquivalent = cvss * 10;
|
|
156
295
|
const delta = rwep - cvssEquivalent;
|
|
157
296
|
|
|
297
|
+
// audit J F15: narrow the "broadly aligned" band from ±20 to ±10. The old
|
|
298
|
+
// ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
|
|
299
|
+
// where the operator-facing point is precisely that the CVSS-calibrated
|
|
300
|
+
// SLA is insufficient. ±10 is the tightest classifier that still treats
|
|
301
|
+
// ordinary CVSS rounding noise as alignment.
|
|
158
302
|
let explanation = '';
|
|
159
|
-
if (delta >
|
|
303
|
+
if (delta > 10) {
|
|
160
304
|
explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
|
|
161
305
|
const driving = [];
|
|
162
306
|
if (entry.cisa_kev) driving.push('CISA KEV (+25)');
|
|
@@ -166,7 +310,7 @@ function compare(cveId, catalog) {
|
|
|
166
310
|
if (entry.patch_required_reboot && !entry.live_patch_available) driving.push('reboot required (+5)');
|
|
167
311
|
explanation += driving.join(', ');
|
|
168
312
|
explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
|
|
169
|
-
} else if (delta < -
|
|
313
|
+
} else if (delta < -10) {
|
|
170
314
|
explanation = `RWEP lower than CVSS equivalent. Mitigating factors: `;
|
|
171
315
|
const mitigating = [];
|
|
172
316
|
if (entry.patch_available) mitigating.push('patch available (-15)');
|
|
@@ -178,15 +322,20 @@ function compare(cveId, catalog) {
|
|
|
178
322
|
explanation = 'CVSS and RWEP are broadly aligned for this CVE.';
|
|
179
323
|
}
|
|
180
324
|
|
|
181
|
-
|
|
325
|
+
const out = {
|
|
182
326
|
cve_id: cveId,
|
|
183
327
|
cvss: cvss,
|
|
184
328
|
rwep: rwep,
|
|
185
329
|
cvss_framework_sla: timeline(cvssEquivalent),
|
|
186
330
|
rwep_actual_sla: timeline(rwep),
|
|
187
331
|
delta,
|
|
188
|
-
explanation
|
|
332
|
+
explanation,
|
|
189
333
|
};
|
|
334
|
+
if (recompute) {
|
|
335
|
+
out.stored_rwep_score = entry.rwep_score;
|
|
336
|
+
out.recomputed = true;
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
190
339
|
}
|
|
191
340
|
|
|
192
341
|
function validate(catalog) {
|
|
@@ -222,4 +371,15 @@ function validate(catalog) {
|
|
|
222
371
|
return errors;
|
|
223
372
|
}
|
|
224
373
|
|
|
225
|
-
module.exports = {
|
|
374
|
+
module.exports = {
|
|
375
|
+
score,
|
|
376
|
+
scoreCustom,
|
|
377
|
+
timeline,
|
|
378
|
+
compare,
|
|
379
|
+
validate,
|
|
380
|
+
validateFactors,
|
|
381
|
+
deriveRwepFromFactors,
|
|
382
|
+
RWEP_WEIGHTS,
|
|
383
|
+
ACTIVE_EXPLOITATION_LADDER,
|
|
384
|
+
RECOGNISED_FACTOR_KEYS,
|
|
385
|
+
};
|
package/lib/sign.js
CHANGED
|
@@ -24,6 +24,38 @@
|
|
|
24
24
|
* validateSkillPath() below). Without this a tampered manifest could
|
|
25
25
|
* sign or verify arbitrary files outside the skills/ tree.
|
|
26
26
|
*
|
|
27
|
+
* Manifest signing contract (must mirror lib/verify.js):
|
|
28
|
+
* After all individual skill signatures are written, sign-all signs
|
|
29
|
+
* the manifest itself. The canonical bytes are computed as:
|
|
30
|
+
* 1. Read the manifest object after all skill signatures land.
|
|
31
|
+
* 2. Delete the top-level `manifest_signature` field if present
|
|
32
|
+
* (idempotency — re-signing after rotation must produce the same
|
|
33
|
+
* canonical bytes whether or not a stale signature is there).
|
|
34
|
+
* 3. Serialize via JSON.stringify(obj, sortedTopLevelKeys, 2). The
|
|
35
|
+
* top-level keys are stringified in lexicographic order so a
|
|
36
|
+
* re-ordered manifest signs to the same bytes. Nested objects
|
|
37
|
+
* keep their natural key order (skills[] entries already follow
|
|
38
|
+
* a stable convention).
|
|
39
|
+
* 4. Apply normalize() (CRLF→LF, BOM strip) — same transform skills
|
|
40
|
+
* use, so the manifest signature survives any line-ending churn.
|
|
41
|
+
* ANY change to canonicalManifestBytes() or this contract requires
|
|
42
|
+
* the matching change in lib/verify.js. A coordinated attacker who
|
|
43
|
+
* rewrites manifest.json + manifest-snapshot.json + manifest-snapshot.sha256
|
|
44
|
+
* without the private key produces a manifest_signature mismatch that
|
|
45
|
+
* lib/verify.js refuses to load.
|
|
46
|
+
*
|
|
47
|
+
* Windows ACL contract:
|
|
48
|
+
* On win32, `fs.writeFileSync(..., { mode: 0o600 })` only affects
|
|
49
|
+
* read-only attributes — it does NOT establish a POSIX-style restrictive
|
|
50
|
+
* ACL. Any process running under the same desktop user can read the key
|
|
51
|
+
* by default ACL inheritance from the parent. After writing
|
|
52
|
+
* .keys/private.pem on Windows, restrictWindowsAcl() shells to icacls
|
|
53
|
+
* to strip inherited entries and grant Full Control only to the current
|
|
54
|
+
* user. If icacls is unavailable (Server Core, exotic shells), the call
|
|
55
|
+
* warns to stderr but does not fail keypair generation — generating the
|
|
56
|
+
* key was the load-bearing step; ACL tightening is best-effort hardening
|
|
57
|
+
* on top.
|
|
58
|
+
*
|
|
27
59
|
* Signing ceremony:
|
|
28
60
|
* 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
|
|
29
61
|
* 2. node lib/sign.js sign-all — sign all skills (after any content change)
|
|
@@ -44,6 +76,7 @@
|
|
|
44
76
|
const fs = require('fs');
|
|
45
77
|
const path = require('path');
|
|
46
78
|
const crypto = require('crypto');
|
|
79
|
+
const { execFileSync } = require('child_process');
|
|
47
80
|
|
|
48
81
|
const ROOT = path.join(__dirname, '..');
|
|
49
82
|
const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
|
|
@@ -79,6 +112,11 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
79
112
|
fs.writeFileSync(PRIVATE_KEY_PATH, privateKey, { encoding: 'utf8', mode: 0o600 });
|
|
80
113
|
fs.writeFileSync(PUBLIC_KEY_PATH, publicKey, { encoding: 'utf8', mode: 0o644 });
|
|
81
114
|
|
|
115
|
+
// Audit I P1-3: on win32, fs.writeFileSync `mode` does not produce
|
|
116
|
+
// a POSIX-style restrictive ACL. Tighten via icacls so other desktop
|
|
117
|
+
// users on the same workstation / CI runner can't read the key.
|
|
118
|
+
restrictWindowsAcl(PRIVATE_KEY_PATH);
|
|
119
|
+
|
|
82
120
|
if (rotate) {
|
|
83
121
|
console.log('[sign] Keypair rotated. All existing signatures are now invalid — run: node lib/sign.js sign-all');
|
|
84
122
|
} else {
|
|
@@ -128,6 +166,16 @@ function signAll() {
|
|
|
128
166
|
signed++;
|
|
129
167
|
}
|
|
130
168
|
|
|
169
|
+
// Audit I P1-4: sign the manifest itself. Removes any existing
|
|
170
|
+
// manifest_signature field so the canonical bytes are deterministic
|
|
171
|
+
// across re-runs, signs with the private key, then writes the result.
|
|
172
|
+
// A coordinated attacker who rewrites the manifest (and snapshot, and
|
|
173
|
+
// snapshot SHA) without the private key produces an invalid manifest
|
|
174
|
+
// signature; lib/verify.js refuses to load the manifest.
|
|
175
|
+
delete manifest.manifest_signature;
|
|
176
|
+
const manifestSig = signCanonicalManifest(manifest, privateKey);
|
|
177
|
+
manifest.manifest_signature = manifestSig;
|
|
178
|
+
|
|
131
179
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
132
180
|
|
|
133
181
|
// S5: verdict line FIRST, fingerprint banner after. An operator
|
|
@@ -136,7 +184,7 @@ function signAll() {
|
|
|
136
184
|
if (errors > 0) {
|
|
137
185
|
console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
|
|
138
186
|
} else {
|
|
139
|
-
console.log(`\n[sign] ${signed} skills signed.`);
|
|
187
|
+
console.log(`\n[sign] ${signed} skills signed. Manifest signed.`);
|
|
140
188
|
}
|
|
141
189
|
printFingerprintBanner();
|
|
142
190
|
|
|
@@ -160,6 +208,11 @@ function signOne(skillName) {
|
|
|
160
208
|
skill.signed_at = new Date().toISOString();
|
|
161
209
|
delete skill.sha256;
|
|
162
210
|
|
|
211
|
+
// P1-4: re-sign the manifest after the per-skill signature changes.
|
|
212
|
+
// Without this a single-skill sign leaves manifest_signature stale.
|
|
213
|
+
delete manifest.manifest_signature;
|
|
214
|
+
manifest.manifest_signature = signCanonicalManifest(manifest, privateKey);
|
|
215
|
+
|
|
163
216
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
164
217
|
console.log(`[sign] Signed: ${skillName}`);
|
|
165
218
|
printFingerprintBanner();
|
|
@@ -244,6 +297,105 @@ function loadManifest() {
|
|
|
244
297
|
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
245
298
|
}
|
|
246
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Audit I P1-4 — canonical byte form of the manifest, used for both
|
|
302
|
+
* signing (lib/sign.js) and verification (lib/verify.js).
|
|
303
|
+
*
|
|
304
|
+
* Contract: the same logical manifest content must produce the same bytes
|
|
305
|
+
* regardless of (a) whether a stale manifest_signature is present, (b)
|
|
306
|
+
* key order at any depth, (c) line endings or BOM.
|
|
307
|
+
*
|
|
308
|
+
* 1. Clone, delete manifest_signature.
|
|
309
|
+
* 2. Recursively sort object keys at every depth (NOT the top-level
|
|
310
|
+
* whitelist trap — see codex P1 PR #12: passing
|
|
311
|
+
* `Object.keys(manifest).sort()` as the JSON.stringify replacer-array
|
|
312
|
+
* treats it as a property allowlist applied to EVERY object level.
|
|
313
|
+
* Nested fields like `skills[].path` and `skills[].signature` got
|
|
314
|
+
* silently dropped from the canonical bytes, letting an attacker
|
|
315
|
+
* swap them without breaking the signature. Now we deep-canonicalize
|
|
316
|
+
* every object).
|
|
317
|
+
* 3. Apply normalize() — strip leading BOM, convert CRLF → LF.
|
|
318
|
+
*
|
|
319
|
+
* @param {object} manifest
|
|
320
|
+
* @returns {Buffer} canonical UTF-8 bytes
|
|
321
|
+
*/
|
|
322
|
+
function canonicalize(value) {
|
|
323
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
324
|
+
if (value && typeof value === 'object') {
|
|
325
|
+
const out = {};
|
|
326
|
+
for (const key of Object.keys(value).sort()) {
|
|
327
|
+
out[key] = canonicalize(value[key]);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function canonicalManifestBytes(manifest) {
|
|
335
|
+
const clone = { ...manifest };
|
|
336
|
+
delete clone.manifest_signature;
|
|
337
|
+
const json = JSON.stringify(canonicalize(clone), null, 2);
|
|
338
|
+
return Buffer.from(normalize(json), 'utf8');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Sign the canonical manifest bytes with the Ed25519 private key.
|
|
343
|
+
* Returns the manifest_signature object literal to splice into the
|
|
344
|
+
* manifest top level.
|
|
345
|
+
*
|
|
346
|
+
* @param {object} manifest
|
|
347
|
+
* @param {string} privateKey PEM-encoded Ed25519 private key
|
|
348
|
+
* @returns {{algorithm:'Ed25519', signature_base64:string, signed_at:string}}
|
|
349
|
+
*/
|
|
350
|
+
function signCanonicalManifest(manifest, privateKey) {
|
|
351
|
+
const bytes = canonicalManifestBytes(manifest);
|
|
352
|
+
const sig = crypto.sign(null, bytes, {
|
|
353
|
+
key: privateKey,
|
|
354
|
+
dsaEncoding: 'ieee-p1363',
|
|
355
|
+
});
|
|
356
|
+
return {
|
|
357
|
+
algorithm: 'Ed25519',
|
|
358
|
+
signature_base64: sig.toString('base64'),
|
|
359
|
+
signed_at: new Date().toISOString(),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Audit I P1-3 — tighten Windows ACL on the private key.
|
|
365
|
+
*
|
|
366
|
+
* fs.writeFileSync({mode: 0o600}) on win32 only affects read-only
|
|
367
|
+
* attributes; the file inherits its ACL from the parent. icacls strips
|
|
368
|
+
* inheritance and grants Full Control only to the current user. Any
|
|
369
|
+
* failure (icacls missing, exotic shell, environment without USERNAME)
|
|
370
|
+
* is warned to stderr — generating the key was the load-bearing step,
|
|
371
|
+
* ACL tightening is best-effort hardening.
|
|
372
|
+
*
|
|
373
|
+
* @param {string} targetPath absolute path of the private key file
|
|
374
|
+
*/
|
|
375
|
+
function restrictWindowsAcl(targetPath) {
|
|
376
|
+
if (process.platform !== 'win32') return;
|
|
377
|
+
const user = process.env.USERNAME;
|
|
378
|
+
if (!user) {
|
|
379
|
+
console.warn('[sign] WARN: USERNAME env var not set — skipping Windows ACL hardening on ' + targetPath);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
execFileSync('icacls', [
|
|
384
|
+
targetPath,
|
|
385
|
+
'/inheritance:r',
|
|
386
|
+
'/grant:r',
|
|
387
|
+
`${user}:F`,
|
|
388
|
+
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.warn(
|
|
391
|
+
'[sign] WARN: icacls hardening failed on ' + targetPath + ': ' +
|
|
392
|
+
((err && err.message) || String(err)) +
|
|
393
|
+
' — the key was written but ACL inheritance was not stripped. ' +
|
|
394
|
+
'Other desktop users on this machine may be able to read it.'
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
247
399
|
function printFingerprintBanner() {
|
|
248
400
|
if (!fs.existsSync(PUBLIC_KEY_PATH)) return;
|
|
249
401
|
try {
|
|
@@ -303,4 +455,13 @@ Signing ceremony (first time):
|
|
|
303
455
|
}
|
|
304
456
|
}
|
|
305
457
|
|
|
306
|
-
module.exports = {
|
|
458
|
+
module.exports = {
|
|
459
|
+
generateKeypair,
|
|
460
|
+
signAll,
|
|
461
|
+
signOne,
|
|
462
|
+
normalize,
|
|
463
|
+
validateSkillPath,
|
|
464
|
+
canonicalManifestBytes,
|
|
465
|
+
signCanonicalManifest,
|
|
466
|
+
restrictWindowsAcl,
|
|
467
|
+
};
|
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
* govern.jurisdiction_obligations[] (the schema does not give
|
|
32
32
|
* jurisdiction_obligations an explicit `id` field; the shipped playbooks
|
|
33
33
|
* reference them by this composite string).
|
|
34
|
+
* - _meta.mutex is symmetric across the whole playbook set: if A lists B,
|
|
35
|
+
* B must list A. Asymmetry surfaces as a warning in v0.12.16 (and will
|
|
36
|
+
* flip to error in v0.13.0) — see checkMutexReciprocity().
|
|
34
37
|
*
|
|
35
38
|
* Finding severity:
|
|
36
39
|
* - error — structural problems that block the runner (missing required
|
|
@@ -397,6 +400,44 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
397
400
|
return findings;
|
|
398
401
|
}
|
|
399
402
|
|
|
403
|
+
/* Cross-playbook mutex-reciprocity check.
|
|
404
|
+
*
|
|
405
|
+
* `_meta.mutex` is a symmetric relation: if playbook A lists B, B must list A.
|
|
406
|
+
* Asymmetry is a latent runner bug — the engine's mutex enforcement only
|
|
407
|
+
* blocks concurrent execution from whichever side declared the conflict, so
|
|
408
|
+
* an asymmetric declaration silently degrades to a race condition when the
|
|
409
|
+
* undeclared side is started first.
|
|
410
|
+
*
|
|
411
|
+
* Emits one warning per asymmetric pair (keyed off the side that declares
|
|
412
|
+
* the edge). v0.12.16 keeps this at warning severity per the patch-class
|
|
413
|
+
* cadence; v0.13.0 will flip it to error via --strict / predeploy
|
|
414
|
+
* `informational: false`.
|
|
415
|
+
*/
|
|
416
|
+
function checkMutexReciprocity(playbooks) {
|
|
417
|
+
const findings = [];
|
|
418
|
+
const mutexMap = new Map();
|
|
419
|
+
for (const pb of playbooks) {
|
|
420
|
+
if (!pb.data || !pb.data._meta || !pb.data._meta.id) continue;
|
|
421
|
+
const id = pb.data._meta.id;
|
|
422
|
+
const mutex = Array.isArray(pb.data._meta.mutex) ? pb.data._meta.mutex : [];
|
|
423
|
+
mutexMap.set(id, new Set(mutex));
|
|
424
|
+
}
|
|
425
|
+
const byPlaybook = new Map(); // playbookId -> array of warning messages
|
|
426
|
+
for (const [id, mset] of mutexMap.entries()) {
|
|
427
|
+
for (const other of mset) {
|
|
428
|
+
const otherSet = mutexMap.get(other);
|
|
429
|
+
if (!otherSet) continue; // unresolved-id warning is already emitted by checkCrossRefs
|
|
430
|
+
if (!otherSet.has(id)) {
|
|
431
|
+
const msg = `_meta.mutex: asymmetric mutex with "${other}" — "${other}" does not list "${id}" in its _meta.mutex. v0.13.0 will flip this to a hard error.`;
|
|
432
|
+
if (!byPlaybook.has(id)) byPlaybook.set(id, []);
|
|
433
|
+
byPlaybook.get(id).push(msg);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
findings.push(byPlaybook);
|
|
438
|
+
return byPlaybook;
|
|
439
|
+
}
|
|
440
|
+
|
|
400
441
|
function main() {
|
|
401
442
|
const opts = parseArgs(process.argv);
|
|
402
443
|
const schema = readJson(SCHEMA_PATH);
|
|
@@ -408,6 +449,7 @@ function main() {
|
|
|
408
449
|
playbookIds.add(pb.data._meta.id);
|
|
409
450
|
}
|
|
410
451
|
}
|
|
452
|
+
const mutexAsymmetries = checkMutexReciprocity(playbooks);
|
|
411
453
|
|
|
412
454
|
let errored = 0;
|
|
413
455
|
let warned = 0;
|
|
@@ -425,6 +467,9 @@ function main() {
|
|
|
425
467
|
...validate(pb.data, schema, 'playbook', label),
|
|
426
468
|
...checkCrossRefs(pb.data, ctx, playbookIds),
|
|
427
469
|
];
|
|
470
|
+
const reciprocityMsgs =
|
|
471
|
+
(pb.data && pb.data._meta && mutexAsymmetries.get(pb.data._meta.id)) || [];
|
|
472
|
+
for (const m of reciprocityMsgs) findings.push({ severity: 'warning', message: m });
|
|
428
473
|
const effective = opts.strict
|
|
429
474
|
? findings.map((f) => ({ ...f, severity: 'error' }))
|
|
430
475
|
: findings;
|
|
@@ -459,6 +504,7 @@ function main() {
|
|
|
459
504
|
module.exports = {
|
|
460
505
|
validate,
|
|
461
506
|
checkCrossRefs,
|
|
507
|
+
checkMutexReciprocity,
|
|
462
508
|
loadContext,
|
|
463
509
|
loadPlaybooks,
|
|
464
510
|
obligationKey,
|