@blamejs/exceptd-skills 0.16.21 → 0.16.23
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/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +28 -0
- package/CONTEXT.md +9 -9
- package/README.md +3 -3
- package/agents/skill-updater.md +1 -1
- package/agents/source-validator.md +3 -4
- package/agents/threat-researcher.md +1 -1
- package/bin/exceptd.js +19 -7
- package/data/_indexes/_meta.json +10 -10
- package/data/_indexes/activity-feed.json +12 -12
- package/data/_indexes/chains.json +70084 -3852
- package/data/_indexes/frequency.json +492 -163
- package/data/_indexes/section-offsets.json +16 -16
- package/data/_indexes/summary-cards.json +272 -106
- package/data/_indexes/token-budget.json +10 -10
- package/data/_indexes/trigger-table.json +15 -6
- package/data/_indexes/xref.json +218 -26
- package/data/cve-catalog.json +10 -10
- package/data/cwe-catalog.json +1 -0
- package/lib/auto-discovery.js +39 -1
- package/lib/collectors/scan-excludes.js +4 -1
- package/lib/cve-cli.js +9 -1
- package/lib/cve-curation.js +8 -1
- package/lib/exit-codes.js +2 -0
- package/lib/flag-suggest.js +1 -1
- package/lib/lint-skills.js +70 -0
- package/lib/playbook-runner.js +59 -11
- package/lib/prefetch.js +24 -1
- package/lib/refresh-external.js +56 -5
- package/lib/rfc-cli.js +8 -1
- package/lib/scoring.js +36 -8
- package/lib/validate-cve-catalog.js +36 -14
- package/lib/validate-package.js +8 -0
- package/lib/validate-playbooks.js +42 -0
- package/lib/verify.js +4 -3
- package/manifest-snapshot.json +4 -2
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +57 -54
- package/orchestrator/index.js +48 -4
- package/orchestrator/scanner.js +53 -5
- package/package.json +1 -1
- package/sbom.cdx.json +80 -80
- package/scripts/build-indexes.js +42 -8
- package/scripts/check-sbom-currency.js +72 -0
- package/scripts/release.js +22 -15
- package/skills/exploit-scoring/skill.md +8 -8
- package/sources/validators/cve-validator.js +6 -1
package/lib/exit-codes.js
CHANGED
|
@@ -27,6 +27,7 @@ const EXIT_CODES = Object.freeze({
|
|
|
27
27
|
LOCK_CONTENTION: 8,
|
|
28
28
|
STORAGE_EXHAUSTED: 9,
|
|
29
29
|
UNKNOWN_COMMAND: 10,
|
|
30
|
+
WATCH_LOCK_CONTENTION: 75,
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -45,6 +46,7 @@ const EXIT_CODE_DESCRIPTIONS = Object.freeze({
|
|
|
45
46
|
8: { name: 'LOCK_CONTENTION', summary: 'Concurrent invocation holds the per-playbook attestation lock; retry after the busy run releases.' },
|
|
46
47
|
9: { name: 'STORAGE_EXHAUSTED', summary: 'Disk full, quota exceeded, or read-only filesystem prevented attestation write (ENOSPC, EDQUOT, EROFS).' },
|
|
47
48
|
10: { name: 'UNKNOWN_COMMAND', summary: 'Unknown verb / dispatcher refused the requested command. Distinct from DETECTED_ESCALATE (2) which means a verb ran and detected an escalation-worthy finding.' },
|
|
49
|
+
75: { name: 'WATCH_LOCK_CONTENTION', summary: 'Watch daemon lock is held by a live process; retry after it exits (sysexits EX_TEMPFAIL). Distinct from LOCK_CONTENTION (8), the per-playbook attestation lock.' },
|
|
48
50
|
});
|
|
49
51
|
|
|
50
52
|
/**
|
package/lib/flag-suggest.js
CHANGED
|
@@ -75,7 +75,7 @@ const VERB_FLAG_ALLOWLIST = Object.freeze({
|
|
|
75
75
|
'publisher-namespace', 'vex', 'diff-from-latest', 'all', 'scope',
|
|
76
76
|
'strict-preconditions', 'ci', 'block-on-jurisdiction-clock', 'upstream-check',
|
|
77
77
|
'session-key', 'tlp', 'bundle-deterministic', 'bundle-epoch',
|
|
78
|
-
'include-judgement-shaped', 'format',
|
|
78
|
+
'include-judgement-shaped', 'format', 'directive', 'explain', 'signal-list',
|
|
79
79
|
],
|
|
80
80
|
ci: [
|
|
81
81
|
'evidence', 'evidence-dir', 'session-id', 'force-overwrite', 'attestation-root',
|
package/lib/lint-skills.js
CHANGED
|
@@ -44,6 +44,8 @@ const { safeExit } = require('./exit-codes');
|
|
|
44
44
|
|
|
45
45
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
46
46
|
const MANIFEST_PATH = path.join(REPO_ROOT, 'manifest.json');
|
|
47
|
+
const FRONTMATTER_SCHEMA_PATH = path.join(__dirname, 'schemas', 'skill-frontmatter.schema.json');
|
|
48
|
+
const FRONTMATTER_SCHEMA = JSON.parse(fs.readFileSync(FRONTMATTER_SCHEMA_PATH, 'utf8'));
|
|
47
49
|
const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
|
|
48
50
|
const DATA_DIR = path.join(REPO_ROOT, 'data');
|
|
49
51
|
const ATLAS_PATH = path.join(DATA_DIR, 'atlas-ttps.json');
|
|
@@ -234,6 +236,20 @@ function unquote(s) {
|
|
|
234
236
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
235
237
|
return s.slice(1, -1);
|
|
236
238
|
}
|
|
239
|
+
// Quoted scalar followed by an inline comment, e.g.
|
|
240
|
+
// "standalone" # why this skill is standalone
|
|
241
|
+
// The value is the quoted content; everything after the closing quote is a
|
|
242
|
+
// YAML comment. Only applied when the trailing segment is whitespace + `#`
|
|
243
|
+
// so a mid-string `#` inside an unquoted value is never mistaken for one.
|
|
244
|
+
if (first === '"' || first === "'") {
|
|
245
|
+
const close = s.indexOf(first, 1);
|
|
246
|
+
if (close > 0) {
|
|
247
|
+
const tail = s.slice(close + 1);
|
|
248
|
+
if (/^\s+#/.test(tail)) {
|
|
249
|
+
return s.slice(1, close);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
237
253
|
}
|
|
238
254
|
return s;
|
|
239
255
|
}
|
|
@@ -253,6 +269,53 @@ function extractFrontmatterBlock(content) {
|
|
|
253
269
|
return { frontmatter: raw.replace(/^\r?\n/, ''), body: bodyStart, frontmatterRaw: raw };
|
|
254
270
|
}
|
|
255
271
|
|
|
272
|
+
// Array-item pattern constraints already enforced above by dedicated regexes
|
|
273
|
+
// (ATLAS_ID_RE / ATTACK_ID_RE / JSON_FILENAME_RE) with their own error wording.
|
|
274
|
+
// The schema-driven pass below skips these so a field is never reported twice.
|
|
275
|
+
const SCHEMA_PATTERN_HANDLED_ELSEWHERE = new Set(['atlas_refs', 'attack_refs', 'data_deps']);
|
|
276
|
+
|
|
277
|
+
/* Enforce the enum and array-item pattern constraints declared in
|
|
278
|
+
* lib/schemas/skill-frontmatter.schema.json, so the shipped schema is the
|
|
279
|
+
* source of truth rather than a decorative artifact. Covers the schema's
|
|
280
|
+
* `enum` constraints (e.g. discovery_mode) and the `items.pattern` format
|
|
281
|
+
* checks on ref arrays (cwe_refs / d3fend_refs / dlp_refs / rfc_refs) that the
|
|
282
|
+
* hand-coded checks did not apply. Returns errors in the existing
|
|
283
|
+
* `frontmatter.<field> ...` voice. */
|
|
284
|
+
function schemaConstraintErrors(fm, schema) {
|
|
285
|
+
const errors = [];
|
|
286
|
+
const props = (schema && schema.properties) || {};
|
|
287
|
+
for (const [field, spec] of Object.entries(props)) {
|
|
288
|
+
if (!(field in fm)) continue;
|
|
289
|
+
const value = fm[field];
|
|
290
|
+
|
|
291
|
+
if (Array.isArray(spec.enum) && typeof value === 'string') {
|
|
292
|
+
if (!spec.enum.includes(value)) {
|
|
293
|
+
errors.push(
|
|
294
|
+
`frontmatter.${field} "${value}" is not one of ${JSON.stringify(spec.enum)}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (
|
|
300
|
+
spec.type === 'array' &&
|
|
301
|
+
spec.items &&
|
|
302
|
+
typeof spec.items.pattern === 'string' &&
|
|
303
|
+
!SCHEMA_PATTERN_HANDLED_ELSEWHERE.has(field) &&
|
|
304
|
+
Array.isArray(value)
|
|
305
|
+
) {
|
|
306
|
+
const itemRe = new RegExp(spec.items.pattern); // allow:dynamic-regex — bundled schema.pattern, not operator input
|
|
307
|
+
for (const item of value) {
|
|
308
|
+
if (typeof item !== 'string' || !itemRe.test(item)) {
|
|
309
|
+
errors.push(
|
|
310
|
+
`frontmatter.${field} entry ${JSON.stringify(item)} does not match the schema pattern /${spec.items.pattern}/`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return errors;
|
|
317
|
+
}
|
|
318
|
+
|
|
256
319
|
/* Validate frontmatter object against the codified schema rules. */
|
|
257
320
|
function validateFrontmatter(fm, skillName) {
|
|
258
321
|
const errors = [];
|
|
@@ -387,6 +450,10 @@ function validateFrontmatter(fm, skillName) {
|
|
|
387
450
|
}
|
|
388
451
|
}
|
|
389
452
|
|
|
453
|
+
// Drive enum + ref-pattern enforcement from the published schema so the
|
|
454
|
+
// shipped artifact and this gate cannot diverge.
|
|
455
|
+
errors.push(...schemaConstraintErrors(fm, FRONTMATTER_SCHEMA));
|
|
456
|
+
|
|
390
457
|
return { errors, warnings };
|
|
391
458
|
}
|
|
392
459
|
|
|
@@ -886,6 +953,9 @@ module.exports = {
|
|
|
886
953
|
COUNTERMEASURE_SECTION,
|
|
887
954
|
COUNTERMEASURE_CUTOFF,
|
|
888
955
|
MIN_SECTION_BODY_WORDS,
|
|
956
|
+
validateFrontmatter,
|
|
957
|
+
schemaConstraintErrors,
|
|
958
|
+
FRONTMATTER_SCHEMA,
|
|
889
959
|
};
|
|
890
960
|
|
|
891
961
|
if (require.main === module) {
|
package/lib/playbook-runner.js
CHANGED
|
@@ -48,6 +48,7 @@ const path = require('path');
|
|
|
48
48
|
const os = require('os');
|
|
49
49
|
const crypto = require('crypto');
|
|
50
50
|
const scoring = require('./scoring');
|
|
51
|
+
const { assertIdComponent } = require('./id-validation');
|
|
51
52
|
const codepointClass = require('../vendor/blamejs/codepoint-class.js');
|
|
52
53
|
|
|
53
54
|
// cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
|
|
@@ -170,6 +171,9 @@ function listPlaybooks() {
|
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
function loadPlaybook(playbookId) {
|
|
174
|
+
// Traversal defense co-located with the path.join — every caller gets it,
|
|
175
|
+
// not just the CLI dispatcher's pre-validation wrapper.
|
|
176
|
+
assertIdComponent(playbookId, 'playbook');
|
|
173
177
|
const p = path.join(PLAYBOOK_DIR, `${playbookId}.json`);
|
|
174
178
|
if (!fs.existsSync(p)) throw new Error(`Playbook not found: ${playbookId} (expected ${p})`);
|
|
175
179
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
@@ -1104,7 +1108,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1104
1108
|
// Aliasing: playbooks ship rwep_factor values `public_poc` and
|
|
1105
1109
|
// `ai_weaponization` for what F5 calls `poc_available` and `ai_factor`.
|
|
1106
1110
|
// Both spellings resolve here.
|
|
1107
|
-
const _activeExploitationLadder =
|
|
1111
|
+
const _activeExploitationLadder = scoring.ACTIVE_EXPLOITATION_LADDER;
|
|
1108
1112
|
const _factorScale = (factorName, cve, blastScore) => {
|
|
1109
1113
|
if (!cve) return 0;
|
|
1110
1114
|
switch (factorName) {
|
|
@@ -1288,17 +1292,16 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1288
1292
|
// not compute new gaps here, just attaches the playbook-declared ones.
|
|
1289
1293
|
const frameworkGaps = an.framework_gap_mapping || [];
|
|
1290
1294
|
|
|
1291
|
-
// escalation criteria
|
|
1295
|
+
// escalation criteria are evaluated AFTER the analyze result is assembled
|
|
1296
|
+
// (below the result literal) so conditions can reference `analyze.*` and
|
|
1297
|
+
// `finding.*` paths — the same roots close()'s feeds_into context
|
|
1298
|
+
// resolves. The flat keys (rwep, blast_radius_score, theater_verdict,
|
|
1299
|
+
// agent signals) remain available unchanged.
|
|
1292
1300
|
const escalations = [];
|
|
1293
1301
|
const runtimeErrors = []; // E3: collect regex-eval errors during analyze
|
|
1294
1302
|
const evalCtxRoot = { _runErrors: runOpts._runErrors || runtimeErrors };
|
|
1295
|
-
for (const ec of an.escalation_criteria || []) {
|
|
1296
|
-
if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals, ...evalCtxRoot }, playbook)) {
|
|
1297
|
-
escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
1303
|
|
|
1301
|
-
|
|
1304
|
+
const result = {
|
|
1302
1305
|
phase: 'analyze',
|
|
1303
1306
|
playbook_id: playbookId,
|
|
1304
1307
|
directive_id: directiveId,
|
|
@@ -1370,6 +1373,26 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1370
1373
|
// observations submitted.
|
|
1371
1374
|
signal_origins_with_collisions: Array.isArray(agentSignals?._signal_origins_collisions) ? agentSignals._signal_origins_collisions.slice() : (Array.isArray(detectResult?._signal_origins_collisions) ? detectResult._signal_origins_collisions.slice() : [])
|
|
1372
1375
|
};
|
|
1376
|
+
|
|
1377
|
+
// analyzeFindingShape is a pure transform but defensive-wrap it (same as
|
|
1378
|
+
// close()) so a malformed analyze result can't abort escalation handling.
|
|
1379
|
+
let findingShape;
|
|
1380
|
+
try { findingShape = analyzeFindingShape(result); }
|
|
1381
|
+
catch (e) {
|
|
1382
|
+
if (Array.isArray(runOpts._runErrors)) {
|
|
1383
|
+
pushRunError(runOpts._runErrors, { kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: x => x.message || '' });
|
|
1384
|
+
}
|
|
1385
|
+
findingShape = {};
|
|
1386
|
+
}
|
|
1387
|
+
for (const ec of an.escalation_criteria || []) {
|
|
1388
|
+
if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, analyze: result, finding: findingShape, ...agentSignals, ...evalCtxRoot }, playbook)) {
|
|
1389
|
+
escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
// Escalation evaluation may have appended regex-eval diagnostics; refresh
|
|
1393
|
+
// the snapshot so analyze.runtime_errors reflects them.
|
|
1394
|
+
result.runtime_errors = (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []);
|
|
1395
|
+
return result;
|
|
1373
1396
|
}
|
|
1374
1397
|
|
|
1375
1398
|
/**
|
|
@@ -1638,7 +1661,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1638
1661
|
// upstream `govern.jurisdiction_obligations` has the real data — carry it
|
|
1639
1662
|
// forward. `notification_deadline` is published as an alias for `deadline`
|
|
1640
1663
|
// (matches the field name compliance teams expect on a notification record).
|
|
1641
|
-
const
|
|
1664
|
+
const enrichNotification = (na) => {
|
|
1642
1665
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1643
1666
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
1644
1667
|
);
|
|
@@ -1704,7 +1727,29 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1704
1727
|
return { draft_notification: draft, missing_interpolation_vars: missing };
|
|
1705
1728
|
})(),
|
|
1706
1729
|
};
|
|
1707
|
-
}
|
|
1730
|
+
};
|
|
1731
|
+
const notificationActions = (c.notification_actions || []).map(enrichNotification);
|
|
1732
|
+
|
|
1733
|
+
// A govern obligation that declares a notification duty but has no
|
|
1734
|
+
// matching close.notification_actions entry would otherwise never surface
|
|
1735
|
+
// in the phase-7 record set — its regulatory clock stays invisible to
|
|
1736
|
+
// operators reading jurisdiction_notifications. Synthesize a record for
|
|
1737
|
+
// each uncovered notify-type obligation so the deadline is computed and
|
|
1738
|
+
// visible. The synthesized entry carries no draft template (the playbook
|
|
1739
|
+
// declared none) and is marked synthesized_from_obligation so operators
|
|
1740
|
+
// can tell it apart from a playbook-authored action.
|
|
1741
|
+
const coveredObligationRefs = new Set((c.notification_actions || []).map(na => na.obligation_ref));
|
|
1742
|
+
for (const o of (g.jurisdiction_obligations || [])) {
|
|
1743
|
+
if (!String(o.obligation || '').startsWith('notify')) continue;
|
|
1744
|
+
const ref = `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
|
|
1745
|
+
if (coveredObligationRefs.has(ref)) continue;
|
|
1746
|
+
notificationActions.push(enrichNotification({
|
|
1747
|
+
obligation_ref: ref,
|
|
1748
|
+
recipient: null,
|
|
1749
|
+
draft_notification: null,
|
|
1750
|
+
synthesized_from_obligation: true,
|
|
1751
|
+
}));
|
|
1752
|
+
}
|
|
1708
1753
|
|
|
1709
1754
|
// exception_generation — evaluate trigger.
|
|
1710
1755
|
let exception = null;
|
|
@@ -3741,7 +3786,7 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
3741
3786
|
// analyze() can surface analyze.runtime_errors[] without losing the
|
|
3742
3787
|
// diagnostic.
|
|
3743
3788
|
try {
|
|
3744
|
-
return new RegExp(m[2], 'i').test(val); // allow:dynamic-regex — m[2]
|
|
3789
|
+
return new RegExp(m[2], 'i').test(val); // allow:dynamic-regex — m[2] comes from an Ed25519-signed catalog playbook condition (/…/), so the pattern cannot be attacker-controlled without breaking the signature; the try/catch covers construction-time syntax errors only (it does NOT defend against catastrophic backtracking — do not reuse this shape for operator-supplied patterns)
|
|
3745
3790
|
} catch (e) {
|
|
3746
3791
|
const errorRec = { _regex_eval_error: { source: m[1], expr: m[2], message: e && e.message ? String(e.message) : String(e) } };
|
|
3747
3792
|
// Two sites where ctx may carry an accumulator: runOpts._runErrors
|
|
@@ -3955,4 +4000,7 @@ module.exports = {
|
|
|
3955
4000
|
_lockFilePath: lockFilePath,
|
|
3956
4001
|
_vulnIdToUrn: vulnIdToUrn,
|
|
3957
4002
|
_worstActiveExploitation: worstActiveExploitation,
|
|
4003
|
+
// Re-exported from scoring so parity between the catalog scorer and the
|
|
4004
|
+
// runtime evaluator is checkable (and enforced by a test) at the seam.
|
|
4005
|
+
_activeExploitationLadder: scoring.ACTIVE_EXPLOITATION_LADDER,
|
|
3958
4006
|
};
|
package/lib/prefetch.js
CHANGED
|
@@ -386,9 +386,32 @@ function verifyIndexSignature(cacheDir) {
|
|
|
386
386
|
}
|
|
387
387
|
const pubPath = path.join(ROOT, "keys", "public.pem");
|
|
388
388
|
if (!fs.existsSync(pubPath)) return { status: "invalid", reason: "keys/public.pem absent — cannot verify cache signature" };
|
|
389
|
+
const pubPem = fs.readFileSync(pubPath, "utf8");
|
|
390
|
+
// Consult keys/EXPECTED_FINGERPRINT BEFORE crypto.verify, the same external
|
|
391
|
+
// trust anchor every other signature-verifying ingest site enforces. A
|
|
392
|
+
// host-local keys/public.pem swap paired with an attacker-signed
|
|
393
|
+
// _index.json.sig would otherwise authenticate against the attacker's own
|
|
394
|
+
// key (the signature verifies against whatever public.pem is present). The
|
|
395
|
+
// pin is the off-host anchor that closes that gap; honor KEYS_ROTATED=1 for
|
|
396
|
+
// legitimate rotations and warn-and-continue when no pin file is present.
|
|
397
|
+
try {
|
|
398
|
+
const { publicKeyFingerprint, checkExpectedFingerprint } = require("./verify.js");
|
|
399
|
+
const pinResult = checkExpectedFingerprint(publicKeyFingerprint(pubPem));
|
|
400
|
+
if (pinResult.status === "mismatch" && !pinResult.rotationOverride) {
|
|
401
|
+
return {
|
|
402
|
+
status: "invalid",
|
|
403
|
+
reason: `fingerprint-mismatch: live=${pinResult.actual} pin=${pinResult.expected} — keys/public.pem does not match keys/EXPECTED_FINGERPRINT. If this is an intentional rotation, set KEYS_ROTATED=1 and update the pin.`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
// verify.js unavailable (partial install). The caller (loadCtx) already
|
|
408
|
+
// treats a verifier-unavailable signature path as a hard refusal unless
|
|
409
|
+
// --force-stale, so falling through to the signature check below keeps
|
|
410
|
+
// behavior no weaker than before the pin was added.
|
|
411
|
+
}
|
|
389
412
|
const idx = JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
|
390
413
|
const bytes = canonicalIndexBytes(idx);
|
|
391
|
-
const pubKey = crypto.createPublicKey(
|
|
414
|
+
const pubKey = crypto.createPublicKey(pubPem);
|
|
392
415
|
let sigBytes;
|
|
393
416
|
try { sigBytes = Buffer.from(sidecar.signature_base64, "base64"); }
|
|
394
417
|
catch (e) { return { status: "invalid", reason: `signature_base64 decode: ${e.message}` }; }
|
package/lib/refresh-external.js
CHANGED
|
@@ -294,6 +294,33 @@ const KEV_SOURCE = {
|
|
|
294
294
|
continue;
|
|
295
295
|
}
|
|
296
296
|
catalog[d.id][d.field] = d.after;
|
|
297
|
+
// A cisa_kev flip changes the entry's RWEP: the KEV factor carries
|
|
298
|
+
// RWEP_WEIGHTS.cisa_kev points, and the catalog invariant requires
|
|
299
|
+
// rwep_score to equal the factor sum. Writing the flag without the
|
|
300
|
+
// factor + score left entries failing scoring.validate() (stored 45
|
|
301
|
+
// vs computed 70 on the first real KEV listing the refresh applied).
|
|
302
|
+
if (d.field === "cisa_kev") {
|
|
303
|
+
const entry = catalog[d.id];
|
|
304
|
+
if (entry.rwep_factors && typeof entry.rwep_factors === "object") {
|
|
305
|
+
const scoring = require("./scoring");
|
|
306
|
+
// Match the stored factor shape: Shape A keeps the boolean,
|
|
307
|
+
// Shape B (the catalog norm) stores the post-weight contribution.
|
|
308
|
+
entry.rwep_factors.cisa_kev =
|
|
309
|
+
typeof entry.rwep_factors.cisa_kev === "boolean"
|
|
310
|
+
? !!d.after
|
|
311
|
+
: (d.after ? scoring.RWEP_WEIGHTS.cisa_kev : 0);
|
|
312
|
+
entry.rwep_score = scoring.deriveRwepFromFactors(entry.rwep_factors);
|
|
313
|
+
}
|
|
314
|
+
// A de-listing (true→false) leaves the listing date orphaned: the
|
|
315
|
+
// CVE is no longer KEV-listed, so its dateAdded is stale intel.
|
|
316
|
+
// The upstream diff producer only emits a cisa_kev_date diff when
|
|
317
|
+
// upstream has a date, which a de-listed CVE no longer does — so
|
|
318
|
+
// nothing else clears it. Drop the now-meaningless date fields here.
|
|
319
|
+
if (d.after === false) {
|
|
320
|
+
if ("cisa_kev_date" in entry) entry.cisa_kev_date = null;
|
|
321
|
+
if ("cisa_kev_due_date" in entry) entry.cisa_kev_due_date = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
297
324
|
catalog[d.id].last_verified = TODAY;
|
|
298
325
|
updated++;
|
|
299
326
|
}
|
|
@@ -595,9 +622,17 @@ const GHSA_SOURCE = {
|
|
|
595
622
|
async fetchDiff(ctx) {
|
|
596
623
|
if (ctx.fixtures?.ghsa) return synthesizeFromFixture(ctx, "ghsa");
|
|
597
624
|
if (ctx.cacheDir) {
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
625
|
+
// --from-cache is the offline ingest path: every source reads only
|
|
626
|
+
// local cache files, never the network. GHSA has no cache layer, so
|
|
627
|
+
// there is nothing to read offline. Skip it with a structured status
|
|
628
|
+
// instead of falling through to the live api.github.com fetch, which
|
|
629
|
+
// would silently egress on a host the operator believes is isolated.
|
|
630
|
+
return {
|
|
631
|
+
status: "unreachable",
|
|
632
|
+
diffs: [],
|
|
633
|
+
errors: 0,
|
|
634
|
+
summary: "GHSA: no cache layer; skipped in --from-cache mode (would require a live network call)",
|
|
635
|
+
};
|
|
601
636
|
}
|
|
602
637
|
const ghsa = require("./source-ghsa");
|
|
603
638
|
return ghsa.buildDiff(ctx);
|
|
@@ -652,6 +687,18 @@ const OSV_SOURCE = {
|
|
|
652
687
|
applies_to: "data/cve-catalog.json",
|
|
653
688
|
async fetchDiff(ctx) {
|
|
654
689
|
if (ctx.fixtures?.osv) return synthesizeFromFixture(ctx, "osv");
|
|
690
|
+
if (ctx.cacheDir) {
|
|
691
|
+
// --from-cache is the offline ingest path. OSV resolves advisories by
|
|
692
|
+
// live id lookup (ctx.osv_ids) and has no cache layer, so skip it with
|
|
693
|
+
// a structured status rather than risk a live osv.dev fetch on a host
|
|
694
|
+
// the operator believes is isolated.
|
|
695
|
+
return {
|
|
696
|
+
status: "unreachable",
|
|
697
|
+
diffs: [],
|
|
698
|
+
errors: 0,
|
|
699
|
+
summary: "OSV: no cache layer; skipped in --from-cache mode (would require a live network call)",
|
|
700
|
+
};
|
|
701
|
+
}
|
|
655
702
|
const osv = require("./source-osv");
|
|
656
703
|
return osv.buildDiff(ctx);
|
|
657
704
|
},
|
|
@@ -830,8 +877,12 @@ function kevDiffFromCache(ctx) {
|
|
|
830
877
|
diffs.push({ id, field: "cisa_kev", before: entry.cisa_kev, after: upstream, severity: "high" });
|
|
831
878
|
}
|
|
832
879
|
const upDate = kevDates.get(id) || null;
|
|
833
|
-
|
|
834
|
-
|
|
880
|
+
// First listings arrive with a null local date — emit the date diff
|
|
881
|
+
// whenever upstream has one that the local entry lacks or contradicts,
|
|
882
|
+
// so the flag flip and its listing date apply together (the strict
|
|
883
|
+
// catalog validator requires KEV-listed entries to carry the date).
|
|
884
|
+
if (upDate && (entry.cisa_kev_date || null) !== upDate) {
|
|
885
|
+
diffs.push({ id, field: "cisa_kev_date", before: entry.cisa_kev_date ?? null, after: upDate, severity: "low" });
|
|
835
886
|
}
|
|
836
887
|
}
|
|
837
888
|
return { status: "ok", diffs, errors: 0, summary: `${diffs.length} KEV diffs (from cache)` };
|
package/lib/rfc-cli.js
CHANGED
|
@@ -83,4 +83,11 @@ const { resolveRfc } = require("./citation-resolve.js");
|
|
|
83
83
|
}
|
|
84
84
|
// A mismatched or nonexistent citation is a non-zero exit for gates.
|
|
85
85
|
if (fails) process.exitCode = 2;
|
|
86
|
-
})()
|
|
86
|
+
})().catch((err) => {
|
|
87
|
+
// A corrupt/unreadable RFC index (or any unexpected throw inside the async
|
|
88
|
+
// body) becomes a rejected promise. Emit the documented {ok:false,error}
|
|
89
|
+
// envelope rather than crashing with a raw stack trace, and signal failure
|
|
90
|
+
// via exitCode so the event loop drains stderr before exit.
|
|
91
|
+
process.stderr.write(JSON.stringify({ ok: false, verb: "rfc", error: String((err && err.message) || err) }) + "\n");
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
});
|
package/lib/scoring.js
CHANGED
|
@@ -39,13 +39,12 @@
|
|
|
39
39
|
* ----------------------------------------------------------------------------
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
];
|
|
42
|
+
// Required-field list is loaded from the catalog schema's entry-level
|
|
43
|
+
// `required` array so the two can never drift. (live_patch_tools is
|
|
44
|
+
// deliberately NOT hard-required here — it is schema-optional, and the
|
|
45
|
+
// live_patch_available => live_patch_tools implication is enforced
|
|
46
|
+
// separately below.)
|
|
47
|
+
const CVE_SCHEMA_REQUIRED = require('./schemas/cve-catalog.schema.json').required;
|
|
49
48
|
|
|
50
49
|
// blast_radius range is 0-30; represents breadth of affected population.
|
|
51
50
|
// AI-discovered and AI-assisted-weaponization both contribute the ai_factor (+15).
|
|
@@ -383,7 +382,12 @@ function compare(cveId, catalog, opts) {
|
|
|
383
382
|
*/
|
|
384
383
|
function detectFactorShape(factors) {
|
|
385
384
|
if (!factors || typeof factors !== 'object') return 'unknown';
|
|
386
|
-
|
|
385
|
+
// Keys inspected for shape evidence. Covers BOTH spellings per factor:
|
|
386
|
+
// the Shape A (raw boolean) names AND the Shape B (post-weight) names the
|
|
387
|
+
// schema requires on rwep_factors — ai_factor and reboot_required — so a
|
|
388
|
+
// post-weight integer on either canonical key registers as Shape B
|
|
389
|
+
// evidence instead of slipping past the mixed-shape detector.
|
|
390
|
+
const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weaponization', 'ai_discovered', 'ai_factor', 'active_exploitation', 'patch_available', 'live_patch_available', 'patch_required_reboot', 'reboot_required'];
|
|
387
391
|
let sawBool = false;
|
|
388
392
|
let sawWeightedInt = false;
|
|
389
393
|
for (const [k, v] of Object.entries(factors)) {
|
|
@@ -440,6 +444,30 @@ function validate(catalog) {
|
|
|
440
444
|
if (shape === 'mixed') {
|
|
441
445
|
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.`);
|
|
442
446
|
}
|
|
447
|
+
// Per-factor coherence: in a Shape B (post-weight) block every stored
|
|
448
|
+
// contribution must equal the weight implied by its source field.
|
|
449
|
+
// Without this, two compensating per-factor errors cancel inside the
|
|
450
|
+
// ±5 aggregate tolerance below and a factor block that contradicts the
|
|
451
|
+
// entry's own flags ships unnoticed. blast_radius is exempt — it is the
|
|
452
|
+
// one judgment-set factor with no deriving source field.
|
|
453
|
+
if (shape === 'B') {
|
|
454
|
+
const f = entry.rwep_factors;
|
|
455
|
+
const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[entry.active_exploitation] ?? 0;
|
|
456
|
+
const implied = {
|
|
457
|
+
cisa_kev: entry.cisa_kev === true ? RWEP_WEIGHTS.cisa_kev : 0,
|
|
458
|
+
poc_available: entry.poc_available === true ? RWEP_WEIGHTS.poc_available : 0,
|
|
459
|
+
ai_factor: (entry.ai_assisted_weaponization === true || entry.ai_discovered === true) ? RWEP_WEIGHTS.ai_factor : 0,
|
|
460
|
+
active_exploitation: RWEP_WEIGHTS.active_exploitation * aeMultiplier,
|
|
461
|
+
patch_available: entry.patch_available === true ? RWEP_WEIGHTS.patch_available : 0,
|
|
462
|
+
live_patch_available: entry.live_patch_available === true ? RWEP_WEIGHTS.live_patch_available : 0,
|
|
463
|
+
reboot_required: entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0,
|
|
464
|
+
};
|
|
465
|
+
for (const [k, want] of Object.entries(implied)) {
|
|
466
|
+
if (k in f && typeof f[k] === 'number' && f[k] !== want) {
|
|
467
|
+
errors.push(`${cveId}: rwep_factors.${k} is ${f[k]} but the entry's source fields imply ${want}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
443
471
|
const calculatedRwep = scoreCustom({
|
|
444
472
|
cisa_kev: entry.cisa_kev,
|
|
445
473
|
poc_available: entry.poc_available,
|
|
@@ -313,6 +313,23 @@ function additionalChecks(key, entry, ctx) {
|
|
|
313
313
|
return warnings;
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
// Return the list of catalogs whose hand-maintained _meta.entry_count disagrees
|
|
317
|
+
// with the live count of non-metadata top-level keys. Catalogs without a numeric
|
|
318
|
+
// _meta.entry_count (or absent/null catalogs) are skipped, so any loaded catalog
|
|
319
|
+
// that adopts the field is covered without editing this function.
|
|
320
|
+
function entryCountMismatches(catalogs) {
|
|
321
|
+
const failures = [];
|
|
322
|
+
for (const { name, catalog: cat } of catalogs) {
|
|
323
|
+
if (cat && cat._meta && typeof cat._meta.entry_count === 'number') {
|
|
324
|
+
const actual = Object.keys(cat).filter((k) => !k.startsWith('_')).length;
|
|
325
|
+
if (cat._meta.entry_count !== actual) {
|
|
326
|
+
failures.push({ name, declared: cat._meta.entry_count, actual });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return failures;
|
|
331
|
+
}
|
|
332
|
+
|
|
316
333
|
function main() {
|
|
317
334
|
const opts = parseArgs(process.argv);
|
|
318
335
|
if (opts === null) return; // parseArgs handled --help / bad-arg and set the exit code
|
|
@@ -374,24 +391,28 @@ function main() {
|
|
|
374
391
|
// Guard hand-maintained _meta.entry_count fields against silent drift. The
|
|
375
392
|
// framework-control-gaps counter once declared 184 while the file held 192
|
|
376
393
|
// and nothing caught it; the zeroday-lessons counter drifted to 68 while the
|
|
377
|
-
// file held 422 because only framework-control-gaps was gated. This
|
|
378
|
-
//
|
|
379
|
-
//
|
|
394
|
+
// file held 422 because only framework-control-gaps was gated. This checks
|
|
395
|
+
// EVERY loaded catalog that declares a numeric _meta.entry_count, so a new
|
|
396
|
+
// catalog with the field is covered automatically. The covered set is derived
|
|
397
|
+
// from the catalogs main() already loaded, not a fixed allowlist.
|
|
380
398
|
const ENTRY_COUNT_CATALOGS = [
|
|
381
|
-
{ name: '
|
|
399
|
+
{ name: 'cve-catalog', catalog },
|
|
382
400
|
{ name: 'zeroday-lessons', catalog: lessons },
|
|
401
|
+
{ name: 'atlas-ttps', catalog: atlas },
|
|
402
|
+
{ name: 'cwe-catalog', catalog: cwe },
|
|
403
|
+
{ name: 'attack-techniques', catalog: attack },
|
|
404
|
+
{ name: 'd3fend-catalog', catalog: d3fend },
|
|
405
|
+
{ name: 'framework-control-gaps', catalog: frameworks },
|
|
383
406
|
];
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
);
|
|
392
|
-
process.exit(1);
|
|
393
|
-
}
|
|
407
|
+
const entryCountFailures = entryCountMismatches(ENTRY_COUNT_CATALOGS);
|
|
408
|
+
if (entryCountFailures.length > 0) {
|
|
409
|
+
for (const f of entryCountFailures) {
|
|
410
|
+
process.stderr.write(
|
|
411
|
+
`[validate-cve-catalog] FAIL: ${f.name} _meta.entry_count (${f.declared}) ` +
|
|
412
|
+
`!= actual entry count (${f.actual}). Update _meta.entry_count to ${f.actual}.\n`,
|
|
413
|
+
);
|
|
394
414
|
}
|
|
415
|
+
process.exit(1);
|
|
395
416
|
}
|
|
396
417
|
|
|
397
418
|
let failed = 0;
|
|
@@ -496,6 +517,7 @@ module.exports = {
|
|
|
496
517
|
looksLikePublicExploitSource,
|
|
497
518
|
isUsableDate,
|
|
498
519
|
additionalChecks,
|
|
520
|
+
entryCountMismatches,
|
|
499
521
|
PUBLIC_EXPLOIT_URL_PATTERNS,
|
|
500
522
|
STRICT_CVSS_PATTERN,
|
|
501
523
|
};
|
package/lib/validate-package.js
CHANGED
|
@@ -40,6 +40,14 @@ const REQUIRED_PATHS = [
|
|
|
40
40
|
"manifest-snapshot.json",
|
|
41
41
|
"sbom.cdx.json",
|
|
42
42
|
"bin/exceptd.js",
|
|
43
|
+
// Modules require()d at the top of bin/exceptd.js on every invocation —
|
|
44
|
+
// dropping any of these from the tarball bricks the CLI at launch with a
|
|
45
|
+
// module-not-found, so they are pinned explicitly rather than relied on via
|
|
46
|
+
// the directory-level files[] entries.
|
|
47
|
+
"lib/exit-codes.js",
|
|
48
|
+
"lib/id-validation.js",
|
|
49
|
+
"lib/flag-suggest.js",
|
|
50
|
+
"vendor/blamejs/codepoint-class.js",
|
|
43
51
|
"lib/refresh-external.js",
|
|
44
52
|
"lib/job-queue.js",
|
|
45
53
|
"lib/prefetch.js",
|
|
@@ -475,6 +475,48 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
475
475
|
}
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
// Escalation / feeds_into condition path-root resolvability. The
|
|
479
|
+
// analyze-phase escalation context resolves the flat keys (rwep,
|
|
480
|
+
// blast_radius_score, theater_verdict, agent signals) plus the `analyze`
|
|
481
|
+
// and `finding` roots; close()'s feeds_into context additionally resolves
|
|
482
|
+
// `validate` and `theater_score`. A dotted path rooted at any OTHER phase
|
|
483
|
+
// name can never resolve at evaluation time — the condition would
|
|
484
|
+
// silently never fire — so it is rejected here. Bare (un-dotted)
|
|
485
|
+
// identifiers are agent-signal names, an open vocabulary, and are not
|
|
486
|
+
// checked.
|
|
487
|
+
const conditionPathRoots = (cond) => {
|
|
488
|
+
if (typeof cond !== 'string') return [];
|
|
489
|
+
const stripped = cond.replace(/'[^']*'|"[^"]*"|\/[^/\n]*\//g, ' ');
|
|
490
|
+
const roots = [];
|
|
491
|
+
const re = /(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)(?:\.[A-Za-z_][A-Za-z0-9_]*)+/g;
|
|
492
|
+
let m;
|
|
493
|
+
while ((m = re.exec(stripped)) !== null) roots.push(m[1]);
|
|
494
|
+
return roots;
|
|
495
|
+
};
|
|
496
|
+
const PHASE_NAME_ROOTS = new Set(['govern', 'direct', 'look', 'detect', 'analyze', 'validate', 'close']);
|
|
497
|
+
const ESCALATION_OK_ROOTS = new Set(['analyze', 'finding']);
|
|
498
|
+
const FEEDS_OK_ROOTS = new Set(['analyze', 'validate', 'finding']);
|
|
499
|
+
for (const [i, ec] of (((phases.analyze || {}).escalation_criteria) || []).entries()) {
|
|
500
|
+
if (!ec || typeof ec !== 'object') continue;
|
|
501
|
+
for (const root of conditionPathRoots(ec.condition)) {
|
|
502
|
+
if (PHASE_NAME_ROOTS.has(root) && !ESCALATION_OK_ROOTS.has(root)) {
|
|
503
|
+
err(
|
|
504
|
+
`phases.analyze.escalation_criteria[${i}].condition: path root "${root}." is not resolvable in the escalation context (phase-result roots available there: analyze, finding) — the condition would never fire`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const [i, f] of (meta.feeds_into || []).entries()) {
|
|
510
|
+
if (!f || typeof f !== 'object') continue;
|
|
511
|
+
for (const root of conditionPathRoots(f.condition)) {
|
|
512
|
+
if (PHASE_NAME_ROOTS.has(root) && !FEEDS_OK_ROOTS.has(root)) {
|
|
513
|
+
err(
|
|
514
|
+
`_meta.feeds_into[${i}].condition: path root "${root}." is not resolvable in the feeds_into context (phase-result roots available there: analyze, validate, finding) — the condition would never fire`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
478
520
|
// Air-gap completeness. When _meta.air_gap_mode is true the runner refuses
|
|
479
521
|
// to touch the network, so every artifact whose source is a network call
|
|
480
522
|
// (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry a
|
package/lib/verify.js
CHANGED
|
@@ -602,13 +602,14 @@ function validateAgainstSchema(value, schema, here, root) {
|
|
|
602
602
|
* and returns the first non-comment / non-empty line. Returns null if
|
|
603
603
|
* the file is unreadable / empty.
|
|
604
604
|
*
|
|
605
|
-
* Shared across
|
|
605
|
+
* Shared across five sites so every loader normalises identically:
|
|
606
606
|
* - lib/verify.js (manifest signature gate)
|
|
607
607
|
* - lib/refresh-network.js (refresh-network pre-swap gate)
|
|
608
608
|
* - scripts/verify-shipped-tarball.js (predeploy gate)
|
|
609
609
|
* - bin/exceptd.js (attestation pin)
|
|
610
|
-
*
|
|
611
|
-
*
|
|
610
|
+
* - lib/prefetch.js (cache-consume index signature pin)
|
|
611
|
+
* tests/normalize-contract.test.js asserts byte-identical output across the
|
|
612
|
+
* sites under a BOM + CRLF fuzz corpus.
|
|
612
613
|
*/
|
|
613
614
|
function loadExpectedFingerprintFirstLine(pinPath) {
|
|
614
615
|
let buf;
|
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-06-
|
|
3
|
+
"_generated_at": "2026-06-05T18:14:35.343Z",
|
|
4
4
|
"atlas_version": "5.6.0",
|
|
5
5
|
"skill_count": 51,
|
|
6
6
|
"skills": [
|
|
@@ -155,7 +155,9 @@
|
|
|
155
155
|
"RFC-9421",
|
|
156
156
|
"RFC-9458"
|
|
157
157
|
],
|
|
158
|
-
"cwe_refs": [
|
|
158
|
+
"cwe_refs": [
|
|
159
|
+
"CWE-918"
|
|
160
|
+
],
|
|
159
161
|
"d3fend_refs": [
|
|
160
162
|
"D3-CA",
|
|
161
163
|
"D3-CSPP",
|