@blamejs/exceptd-skills 0.16.22 → 0.16.24
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 +42 -0
- package/CONTEXT.md +9 -9
- package/README.md +3 -3
- package/agents/report-generator.md +2 -2
- 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 +91 -32
- package/data/_indexes/_meta.json +10 -10
- package/data/_indexes/activity-feed.json +12 -12
- package/data/_indexes/chains.json +70435 -4026
- package/data/_indexes/frequency.json +492 -163
- package/data/_indexes/section-offsets.json +51 -51
- 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/ai-api.js +112 -7
- package/lib/collectors/citation-hygiene.js +27 -0
- package/lib/collectors/crypto-codebase.js +25 -0
- package/lib/collectors/kernel.js +32 -2
- package/lib/collectors/library-author.js +30 -0
- package/lib/collectors/runtime.js +38 -3
- package/lib/collectors/sbom.js +21 -2
- package/lib/collectors/scan-excludes.js +4 -1
- package/lib/collectors/secrets.js +125 -0
- package/lib/cve-cli.js +9 -1
- package/lib/cve-curation.js +8 -1
- package/lib/cve-regression-watcher.js +5 -2
- 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 +75 -14
- package/lib/prefetch.js +24 -1
- package/lib/refresh-external.js +32 -3
- 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/README.md +1 -1
- package/orchestrator/index.js +65 -7
- package/orchestrator/scanner.js +53 -5
- package/package.json +1 -1
- package/sbom.cdx.json +110 -110
- package/scripts/build-indexes.js +42 -8
- package/scripts/builders/cwe-chains.js +1 -0
- package/scripts/builders/section-offsets.js +10 -2
- package/scripts/builders/token-budget.js +3 -3
- package/scripts/check-changelog-extract.js +38 -1
- package/scripts/check-sbom-currency.js +72 -0
- package/scripts/check-version-tags.js +5 -0
- package/scripts/release.js +22 -15
- package/skills/exploit-scoring/skill.md +8 -8
package/lib/cve-curation.js
CHANGED
|
@@ -742,7 +742,14 @@ async function cli(argv) {
|
|
|
742
742
|
}
|
|
743
743
|
|
|
744
744
|
if (require.main === module) {
|
|
745
|
-
cli(process.argv.slice(2))
|
|
745
|
+
cli(process.argv.slice(2)).catch((err) => {
|
|
746
|
+
// An apply-path write failure (disk full / read-only FS / permission
|
|
747
|
+
// denied) throws out of cli(). Emit the same {ok:false,verb,mode,error}
|
|
748
|
+
// envelope every other error path in cli() produces instead of crashing
|
|
749
|
+
// with a raw stack trace, and set exitCode so stderr drains before exit.
|
|
750
|
+
process.stderr.write(JSON.stringify({ ok: false, verb: "refresh", mode: "cve-curation", error: String((err && err.message) || err) }) + "\n");
|
|
751
|
+
process.exitCode = 2;
|
|
752
|
+
});
|
|
746
753
|
}
|
|
747
754
|
|
|
748
755
|
module.exports = { curate, keywordOverlapScore, resolveCatalogPath, autoImportedFrom, severityWord, residualWarnings };
|
|
@@ -63,7 +63,6 @@ const path = require('path');
|
|
|
63
63
|
const fs = require('fs');
|
|
64
64
|
|
|
65
65
|
const CVE_ID_RE = /^CVE-((?:19|20)\d{2})-\d{4,7}$/;
|
|
66
|
-
const TODAY = new Date().toISOString().slice(0, 10);
|
|
67
66
|
|
|
68
67
|
/**
|
|
69
68
|
* Extract the year from a CVE-YYYY-NNN identifier. Returns null for non-CVE
|
|
@@ -274,7 +273,11 @@ function findRegressionCandidates(diffs, catalog, opts) {
|
|
|
274
273
|
candidates,
|
|
275
274
|
historical_id_threshold_year: thresholdYear,
|
|
276
275
|
evaluated_diffs: evaluated,
|
|
277
|
-
|
|
276
|
+
// Stamp from the same clock that derives the threshold year, so both
|
|
277
|
+
// date-derived report fields share one instant. Falls back to call-time
|
|
278
|
+
// `new Date()` (via the `now` default) when no clock is injected — never
|
|
279
|
+
// a module-load constant, which would go stale in a long-lived process.
|
|
280
|
+
generated_at: now.toISOString().slice(0, 10),
|
|
278
281
|
control_ref: 'NEW-CTRL-074',
|
|
279
282
|
};
|
|
280
283
|
}
|
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;
|
|
@@ -3380,9 +3425,22 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3380
3425
|
if (runOpts.session_id) {
|
|
3381
3426
|
sessionId = runOpts.session_id;
|
|
3382
3427
|
} else if (runOpts.bundleDeterministic) {
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
.
|
|
3428
|
+
let submissionDigest;
|
|
3429
|
+
try {
|
|
3430
|
+
submissionDigest = crypto.createHash('sha256')
|
|
3431
|
+
.update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
|
|
3432
|
+
.digest('hex');
|
|
3433
|
+
} catch (e) {
|
|
3434
|
+
// canonicalStringify deliberately throws (EVIDENCE_TOO_DEEP) on
|
|
3435
|
+
// pathological nesting. The mutex lockfile and the _activeRuns entry
|
|
3436
|
+
// are already held here, but the protecting try/finally below has
|
|
3437
|
+
// not opened yet — release both before rethrowing, or the leaked
|
|
3438
|
+
// lockfile blocks every subsequent run of this playbook for as long
|
|
3439
|
+
// as this PID lives.
|
|
3440
|
+
_activeRuns.delete(playbookId);
|
|
3441
|
+
releaseLock(lockPath);
|
|
3442
|
+
throw e;
|
|
3443
|
+
}
|
|
3386
3444
|
sessionId = crypto.createHash('sha256')
|
|
3387
3445
|
.update(`${playbookId}\0${submissionDigest}\0${getEngineVersion()}`)
|
|
3388
3446
|
.digest('hex')
|
|
@@ -3741,7 +3799,7 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
3741
3799
|
// analyze() can surface analyze.runtime_errors[] without losing the
|
|
3742
3800
|
// diagnostic.
|
|
3743
3801
|
try {
|
|
3744
|
-
return new RegExp(m[2], 'i').test(val); // allow:dynamic-regex — m[2]
|
|
3802
|
+
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
3803
|
} catch (e) {
|
|
3746
3804
|
const errorRec = { _regex_eval_error: { source: m[1], expr: m[2], message: e && e.message ? String(e.message) : String(e) } };
|
|
3747
3805
|
// Two sites where ctx may carry an accumulator: runOpts._runErrors
|
|
@@ -3955,4 +4013,7 @@ module.exports = {
|
|
|
3955
4013
|
_lockFilePath: lockFilePath,
|
|
3956
4014
|
_vulnIdToUrn: vulnIdToUrn,
|
|
3957
4015
|
_worstActiveExploitation: worstActiveExploitation,
|
|
4016
|
+
// Re-exported from scoring so parity between the catalog scorer and the
|
|
4017
|
+
// runtime evaluator is checkable (and enforced by a test) at the seam.
|
|
4018
|
+
_activeExploitationLadder: scoring.ACTIVE_EXPLOITATION_LADDER,
|
|
3958
4019
|
};
|
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
|
@@ -311,6 +311,15 @@ const KEV_SOURCE = {
|
|
|
311
311
|
: (d.after ? scoring.RWEP_WEIGHTS.cisa_kev : 0);
|
|
312
312
|
entry.rwep_score = scoring.deriveRwepFromFactors(entry.rwep_factors);
|
|
313
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
|
+
}
|
|
314
323
|
}
|
|
315
324
|
catalog[d.id].last_verified = TODAY;
|
|
316
325
|
updated++;
|
|
@@ -613,9 +622,17 @@ const GHSA_SOURCE = {
|
|
|
613
622
|
async fetchDiff(ctx) {
|
|
614
623
|
if (ctx.fixtures?.ghsa) return synthesizeFromFixture(ctx, "ghsa");
|
|
615
624
|
if (ctx.cacheDir) {
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
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
|
+
};
|
|
619
636
|
}
|
|
620
637
|
const ghsa = require("./source-ghsa");
|
|
621
638
|
return ghsa.buildDiff(ctx);
|
|
@@ -670,6 +687,18 @@ const OSV_SOURCE = {
|
|
|
670
687
|
applies_to: "data/cve-catalog.json",
|
|
671
688
|
async fetchDiff(ctx) {
|
|
672
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
|
+
}
|
|
673
702
|
const osv = require("./source-osv");
|
|
674
703
|
return osv.buildDiff(ctx);
|
|
675
704
|
},
|
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
|