@blamejs/exceptd-skills 0.12.13 → 0.12.16
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 +217 -0
- package/bin/exceptd.js +522 -27
- package/data/_indexes/_meta.json +45 -45
- package/data/_indexes/activity-feed.json +4 -4
- package/data/_indexes/catalog-summaries.json +29 -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 +319 -76
- package/data/cve-catalog.json +516 -476
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/playbooks/ai-api.json +3 -1
- package/data/playbooks/containers.json +11 -3
- package/data/playbooks/cred-stores.json +3 -1
- package/data/playbooks/crypto-codebase.json +11 -11
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/hardening.json +3 -1
- package/data/playbooks/kernel.json +3 -1
- package/data/playbooks/library-author.json +21 -10
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +3 -1
- package/data/playbooks/sbom.json +2 -2
- package/data/playbooks/secrets.json +3 -1
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +57 -35
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +33 -14
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +742 -78
- package/lib/prefetch.js +30 -8
- package/lib/refresh-external.js +40 -22
- package/lib/refresh-network.js +233 -17
- package/lib/scoring.js +191 -18
- 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 +56 -18
- package/lib/validate-indexes.js +88 -37
- package/lib/validate-playbooks.js +46 -0
- package/lib/verify.js +72 -0
- 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 +150 -17
- package/package.json +3 -1
- package/sbom.cdx.json +7 -7
- 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 +83 -39
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +141 -9
- 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) {
|
|
@@ -33,6 +33,9 @@ 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
34
|
const ATLAS_PATH = path.join(REPO_ROOT, 'data', 'atlas-ttps.json');
|
|
35
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');
|
|
36
39
|
|
|
37
40
|
// v0.12.12 — patterns that mark a verification_sources URL as a public exploit
|
|
38
41
|
// or PoC location. When poc_available: true AND a verification source matches
|
|
@@ -67,15 +70,17 @@ const DATE_FIELDS = [
|
|
|
67
70
|
];
|
|
68
71
|
|
|
69
72
|
function parseArgs(argv) {
|
|
70
|
-
const opts = { quiet: false };
|
|
73
|
+
const opts = { quiet: false, strict: false };
|
|
71
74
|
for (let i = 2; i < argv.length; i++) {
|
|
72
75
|
const a = argv[i];
|
|
73
76
|
if (a === '--quiet' || a === '-q') opts.quiet = true;
|
|
77
|
+
else if (a === '--strict') opts.strict = true;
|
|
74
78
|
else if (a === '--help' || a === '-h') {
|
|
75
79
|
console.log(
|
|
76
|
-
'Usage: node lib/validate-cve-catalog.js [--quiet]\n' +
|
|
80
|
+
'Usage: node lib/validate-cve-catalog.js [--quiet] [--strict]\n' +
|
|
77
81
|
'\n' +
|
|
78
|
-
' --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',
|
|
79
84
|
);
|
|
80
85
|
process.exit(0);
|
|
81
86
|
} else {
|
|
@@ -239,19 +244,31 @@ function additionalChecks(key, entry, ctx) {
|
|
|
239
244
|
}
|
|
240
245
|
|
|
241
246
|
// V2 — Cross-catalog reference resolution. Unresolved refs are warnings
|
|
242
|
-
// for v0.12.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
+
}
|
|
255
272
|
}
|
|
256
273
|
}
|
|
257
274
|
|
|
@@ -285,10 +302,24 @@ function main() {
|
|
|
285
302
|
const lessons = readJson(LESSONS_PATH);
|
|
286
303
|
const atlas = fs.existsSync(ATLAS_PATH) ? readJson(ATLAS_PATH) : {};
|
|
287
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;
|
|
288
310
|
|
|
289
311
|
const ctx = {
|
|
290
312
|
atlasKeys: new Set(Object.keys(atlas).filter((k) => !k.startsWith('_'))),
|
|
291
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,
|
|
292
323
|
};
|
|
293
324
|
|
|
294
325
|
const cveKeys = Object.keys(catalog).filter((k) => !k.startsWith('_'));
|
|
@@ -325,12 +356,18 @@ function main() {
|
|
|
325
356
|
// `exceptd run cve-curation --advisory <id>`.
|
|
326
357
|
const isDraft = entry && (entry._auto_imported === true || entry._draft === true);
|
|
327
358
|
const errors = validate(entry, schema, 'cve', key);
|
|
328
|
-
|
|
359
|
+
let warnings = additionalChecks(key, entry, ctx);
|
|
329
360
|
if (!lessonKeys.has(key) && !isDraft) {
|
|
330
361
|
errors.push(
|
|
331
362
|
`${key}: missing matching entry in data/zeroday-lessons.json (rule #6: zero-day learning is live)`,
|
|
332
363
|
);
|
|
333
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
|
+
}
|
|
334
371
|
if (isDraft) {
|
|
335
372
|
drafts++;
|
|
336
373
|
if (!opts.quiet) {
|
|
@@ -381,7 +418,8 @@ function main() {
|
|
|
381
418
|
(warned ? `, ${warned} with warnings` : '') +
|
|
382
419
|
(failed ? `, ${failed} failed` : '') + '.';
|
|
383
420
|
console.log(summary);
|
|
384
|
-
process.
|
|
421
|
+
// F18: process.exitCode + return so buffered output drains.
|
|
422
|
+
process.exitCode = failed === 0 ? 0 : 1;
|
|
385
423
|
}
|
|
386
424
|
|
|
387
425
|
module.exports = {
|
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 };
|
|
@@ -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,
|
package/lib/verify.js
CHANGED
|
@@ -51,6 +51,14 @@ const SKILLS_DIR = path.join(ROOT, 'skills');
|
|
|
51
51
|
const PUBLIC_KEY_PATH = path.join(ROOT, 'keys', 'public.pem');
|
|
52
52
|
const PRIVATE_KEY_PATH = path.join(ROOT, '.keys', 'private.pem');
|
|
53
53
|
const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
|
|
54
|
+
// Audit G F4 — key-pin file. When present, lib/verify.js compares the live
|
|
55
|
+
// public-key fingerprint against the pinned one and fails the verify run
|
|
56
|
+
// if they differ (unless the operator sets KEYS_ROTATED=1). The file format
|
|
57
|
+
// is a single line "SHA256:<base64>" matching the publicKeyFingerprint()
|
|
58
|
+
// shape. The file is OPTIONAL: when missing, the gate warns-and-continues
|
|
59
|
+
// rather than failing — this preserves bootstrap compatibility on fresh
|
|
60
|
+
// clones / new key ceremonies. Patch-class semantics.
|
|
61
|
+
const EXPECTED_FINGERPRINT_PATH = path.join(ROOT, 'keys', 'EXPECTED_FINGERPRINT');
|
|
54
62
|
|
|
55
63
|
// --- public API ---
|
|
56
64
|
|
|
@@ -386,6 +394,38 @@ function validateAgainstSchema(value, schema, here, root) {
|
|
|
386
394
|
* @param {string|null} pemKey PEM-encoded public key (or null)
|
|
387
395
|
* @returns {{sha256: string, sha3_512: string}|{error: string}}
|
|
388
396
|
*/
|
|
397
|
+
/**
|
|
398
|
+
* Audit G F4 — compare the live public-key fingerprint against the optional
|
|
399
|
+
* pinned fingerprint in keys/EXPECTED_FINGERPRINT. Returns one of:
|
|
400
|
+
* { status: 'no-pin' } — keys/EXPECTED_FINGERPRINT not present.
|
|
401
|
+
* Callers should warn and continue.
|
|
402
|
+
* { status: 'match' } — live fingerprint matches the pin.
|
|
403
|
+
* { status: 'mismatch', — divergence; caller should fail unless
|
|
404
|
+
* expected, actual, KEYS_ROTATED=1 is set in the environment.
|
|
405
|
+
* rotationOverride }
|
|
406
|
+
*
|
|
407
|
+
* @param {{sha256:string}|null} liveFp publicKeyFingerprint() output
|
|
408
|
+
* @param {string} [pinPath] optional override (testability)
|
|
409
|
+
*/
|
|
410
|
+
function checkExpectedFingerprint(liveFp, pinPath) {
|
|
411
|
+
const p = pinPath || EXPECTED_FINGERPRINT_PATH;
|
|
412
|
+
if (!fs.existsSync(p)) return { status: 'no-pin' };
|
|
413
|
+
if (!liveFp || typeof liveFp.sha256 !== 'string') {
|
|
414
|
+
return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
|
|
415
|
+
}
|
|
416
|
+
const expected = fs.readFileSync(p, 'utf8').trim();
|
|
417
|
+
// Tolerate trailing comment / whitespace on the same line; the file's
|
|
418
|
+
// first non-empty line is the canonical fingerprint.
|
|
419
|
+
const firstLine = expected.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || '';
|
|
420
|
+
if (firstLine === liveFp.sha256) return { status: 'match' };
|
|
421
|
+
return {
|
|
422
|
+
status: 'mismatch',
|
|
423
|
+
expected: firstLine,
|
|
424
|
+
actual: liveFp.sha256,
|
|
425
|
+
rotationOverride: process.env.KEYS_ROTATED === '1',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
389
429
|
function publicKeyFingerprint(pemKey) {
|
|
390
430
|
if (!pemKey) return { sha256: '(no key)', sha3_512: '(no key)' };
|
|
391
431
|
try {
|
|
@@ -468,6 +508,35 @@ if (require.main === module) {
|
|
|
468
508
|
console.log(`[verify] ${fp.sha256}`);
|
|
469
509
|
console.log(`[verify] ${fp.sha3_512}`);
|
|
470
510
|
|
|
511
|
+
// Audit G F4 — pin check. When keys/EXPECTED_FINGERPRINT exists, the
|
|
512
|
+
// live fingerprint MUST match it (or KEYS_ROTATED=1 must be set to
|
|
513
|
+
// intentionally override). When the file is absent, emit a single-line
|
|
514
|
+
// warning but continue — fresh clones / bootstrap workflows should not
|
|
515
|
+
// fail the gate before the operator has committed a fingerprint.
|
|
516
|
+
const pinResult = checkExpectedFingerprint(fp);
|
|
517
|
+
if (pinResult.status === 'no-pin') {
|
|
518
|
+
console.warn(
|
|
519
|
+
`[verify] WARN: keys/EXPECTED_FINGERPRINT not present — key-pin check skipped. ` +
|
|
520
|
+
`Create it with the current ${fp.sha256} line to enable pinning.`
|
|
521
|
+
);
|
|
522
|
+
} else if (pinResult.status === 'mismatch') {
|
|
523
|
+
if (pinResult.rotationOverride) {
|
|
524
|
+
console.warn(
|
|
525
|
+
`[verify] WARN: live key fingerprint ${pinResult.actual} differs from pin ` +
|
|
526
|
+
`${pinResult.expected}. KEYS_ROTATED=1 set — accepting rotation. ` +
|
|
527
|
+
`Update keys/EXPECTED_FINGERPRINT to lock the new pin.`
|
|
528
|
+
);
|
|
529
|
+
} else {
|
|
530
|
+
console.error(
|
|
531
|
+
`[verify] FAIL: live key fingerprint ${pinResult.actual} does not match ` +
|
|
532
|
+
`keys/EXPECTED_FINGERPRINT ${pinResult.expected}. ` +
|
|
533
|
+
`If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
|
|
534
|
+
`then commit the new fingerprint to keys/EXPECTED_FINGERPRINT.`
|
|
535
|
+
);
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
471
540
|
if (result.invalid.length > 0) process.exit(1);
|
|
472
541
|
if (result.missing_sig.length > 0) process.exit(1);
|
|
473
542
|
if (result.missing_file.length > 0) process.exit(1);
|
|
@@ -483,4 +552,7 @@ module.exports = {
|
|
|
483
552
|
validateSkillPath,
|
|
484
553
|
loadManifestValidated,
|
|
485
554
|
validateAgainstSchema,
|
|
555
|
+
publicKeyFingerprint,
|
|
556
|
+
checkExpectedFingerprint,
|
|
557
|
+
EXPECTED_FINGERPRINT_PATH,
|
|
486
558
|
};
|
package/manifest-snapshot.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
|
|
3
|
-
"_generated_at": "2026-05-
|
|
3
|
+
"_generated_at": "2026-05-14T15:55:39.383Z",
|
|
4
4
|
"atlas_version": "5.1.0",
|
|
5
5
|
"skill_count": 38,
|
|
6
6
|
"skills": [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ca9d31e533c9d494e1ac5875e0a45176101438c3d75d44387187e367ccae21ad manifest-snapshot.json
|