@blamejs/exceptd-skills 0.18.12 → 0.18.14
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 +20 -0
- package/bin/exceptd.js +26 -0
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/ai-api.json +2 -2
- package/data/playbooks/ai-discovered-cve-triage.json +1 -1
- package/data/playbooks/audit-log-integrity.json +3 -3
- package/data/playbooks/cicd-pipeline-compromise.json +1 -1
- package/data/playbooks/citation-hygiene.json +2 -2
- package/data/playbooks/containers.json +2 -2
- package/data/playbooks/cred-stores.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/decompression-dos.json +3 -3
- package/data/playbooks/framework.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/identity-sso-compromise.json +1 -1
- package/data/playbooks/kernel.json +2 -2
- package/data/playbooks/library-author.json +1 -1
- package/data/playbooks/llm-tool-use-exfil.json +1 -1
- package/data/playbooks/log-injection-telemetry.json +3 -3
- package/data/playbooks/mail-server-hardening.json +3 -3
- package/data/playbooks/mcp.json +2 -2
- package/data/playbooks/multitenancy-isolation.json +3 -3
- package/data/playbooks/network-trust.json +3 -3
- package/data/playbooks/post-quantum-migration.json +1 -1
- package/data/playbooks/privacy-consent-ops.json +3 -3
- package/data/playbooks/runtime.json +2 -2
- package/data/playbooks/sbom.json +1 -1
- package/data/playbooks/secrets.json +1 -1
- package/data/playbooks/self-update-integrity.json +3 -3
- package/data/playbooks/supply-chain-recovery.json +2 -2
- package/data/playbooks/vc-wallet-trust.json +5 -5
- package/data/playbooks/webhook-callback-abuse.json +1 -1
- package/lib/auto-discovery.js +10 -1
- package/lib/cve-regression-watcher.js +5 -1
- package/lib/job-queue.js +9 -2
- package/lib/lint-skills.js +34 -5
- package/lib/playbook-runner.js +51 -4
- package/lib/prefetch.js +15 -7
- package/lib/schemas/skill-frontmatter.schema.json +2 -2
- package/lib/source-ghsa.js +8 -2
- package/lib/source-osv.js +19 -3
- package/lib/ttp-mapper.js +15 -0
- package/lib/upstream-check.js +17 -3
- package/lib/validate-cve-catalog.js +16 -4
- package/lib/validate-package.js +9 -2
- package/lib/validate-playbooks.js +112 -10
- package/lib/validate-vendor.js +11 -5
- package/lib/xml-tokenizer.js +36 -8
- package/manifest.json +53 -53
- package/package.json +1 -1
- package/sbom.cdx.json +117 -117
- package/sources/validators/cve-validator.js +7 -1
- package/sources/validators/rfc-validator.js +21 -3
- package/sources/validators/version-pin-validator.js +39 -2
- package/vendor/blamejs/_PROVENANCE.json +3 -2
- package/vendor/blamejs/worker-pool.js +6 -0
package/lib/auto-discovery.js
CHANGED
|
@@ -206,7 +206,16 @@ function extractNvdMetrics(payload) {
|
|
|
206
206
|
|
|
207
207
|
function extractEpss(payload, id) {
|
|
208
208
|
const data = Array.isArray(payload?.data) ? payload.data : [];
|
|
209
|
-
|
|
209
|
+
// Match the requested CVE id only. The prior `|| data[0]` fallback silently
|
|
210
|
+
// misattributed a DIFFERENT CVE's EPSS score into the draft whenever the
|
|
211
|
+
// requested id was absent from the payload — a single-row response for the
|
|
212
|
+
// wrong CVE (or a stale/mismatched sidecar) wrote that CVE's score under the
|
|
213
|
+
// discovered id. A non-matching payload now yields null (same
|
|
214
|
+
// null-mechanical-field behavior used when no cache exists). The only
|
|
215
|
+
// accepted fallback is a single-row, cve-less response shape (the requested
|
|
216
|
+
// id is implicit because the payload carries exactly one un-keyed row).
|
|
217
|
+
let row = data.find((r) => r?.cve === id);
|
|
218
|
+
if (!row && data.length === 1 && data[0]?.cve == null) row = data[0];
|
|
210
219
|
if (!row) return null;
|
|
211
220
|
return {
|
|
212
221
|
score: row.epss != null ? Number(row.epss) : null,
|
|
@@ -228,7 +228,11 @@ function findRegressionCandidates(diffs, catalog, opts) {
|
|
|
228
228
|
const titleStr = (typeof d.title === 'string' && d.title) ? d.title
|
|
229
229
|
: (typeof d.first_title === 'string' && d.first_title) ? d.first_title
|
|
230
230
|
: '';
|
|
231
|
-
|
|
231
|
+
// Dedupe titles before pushing — mirrors the content-only branch.
|
|
232
|
+
// Without this, duplicate titles across feeds (a common multi-feed
|
|
233
|
+
// surfacing pattern) consume distinct slots and evict genuinely
|
|
234
|
+
// distinct titles from the 5-cap below.
|
|
235
|
+
if (titleStr && !slot.titles.includes(titleStr)) slot.titles.push(titleStr);
|
|
232
236
|
// Merge content signals — the strongest signal wins.
|
|
233
237
|
Object.assign(slot.signals, signals);
|
|
234
238
|
continue;
|
package/lib/job-queue.js
CHANGED
|
@@ -34,8 +34,15 @@ class TokenBucket {
|
|
|
34
34
|
if (elapsed <= 0) return;
|
|
35
35
|
const add = elapsed / this.refillIntervalMs;
|
|
36
36
|
if (add >= 1) {
|
|
37
|
-
|
|
38
|
-
this.
|
|
37
|
+
const whole = Math.floor(add);
|
|
38
|
+
this.tokens = Math.min(this.capacity, this.tokens + whole);
|
|
39
|
+
// Advance lastRefill by the time the whole tokens we just granted
|
|
40
|
+
// consumed, NOT to `now`. Snapping to `now` discards the sub-interval
|
|
41
|
+
// remainder (`elapsed % refillIntervalMs`), so the bucket refills
|
|
42
|
+
// slightly slower than the configured rate and under-utilizes the
|
|
43
|
+
// budget over many refills. Crediting only the consumed time keeps
|
|
44
|
+
// the long-run refill rate exactly tokens/windowMs.
|
|
45
|
+
this.lastRefill += whole * this.refillIntervalMs;
|
|
39
46
|
}
|
|
40
47
|
}
|
|
41
48
|
tryTake() {
|
package/lib/lint-skills.js
CHANGED
|
@@ -117,6 +117,26 @@ const ATLAS_ID_RE = /^AML\.T\d{4}(\.\d{3})?$/;
|
|
|
117
117
|
const ATTACK_ID_RE = /^T\d{4}(\.\d{3})?$/;
|
|
118
118
|
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
119
119
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
120
|
+
|
|
121
|
+
/*
|
|
122
|
+
* Strict ISO calendar-date check. ISO_DATE_RE only proves the SHAPE
|
|
123
|
+
* (YYYY-MM-DD); Date.parse() silently rolls non-calendar dates over —
|
|
124
|
+
* 2026-02-30 becomes 2026-03-02, 2026-04-31 becomes 2026-05-01 — so a
|
|
125
|
+
* malformed review date would pass the staleness gate against a date that
|
|
126
|
+
* never existed. Round-trip through Date.UTC and require every component to
|
|
127
|
+
* survive unchanged, which rejects the rollovers while accepting real dates.
|
|
128
|
+
*/
|
|
129
|
+
function isStrictIsoCalendarDate(s) {
|
|
130
|
+
if (typeof s !== 'string' || !ISO_DATE_RE.test(s)) return false;
|
|
131
|
+
const [y, m, d] = s.split('-').map((n) => parseInt(n, 10));
|
|
132
|
+
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
|
|
133
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
134
|
+
return (
|
|
135
|
+
dt.getUTCFullYear() === y &&
|
|
136
|
+
dt.getUTCMonth() + 1 === m &&
|
|
137
|
+
dt.getUTCDate() === d
|
|
138
|
+
);
|
|
139
|
+
}
|
|
120
140
|
const KEBAB_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
121
141
|
const JSON_FILENAME_RE = /^[A-Za-z0-9._-]+\.json$/;
|
|
122
142
|
|
|
@@ -293,8 +313,17 @@ function schemaConstraintErrors(fm, schema) {
|
|
|
293
313
|
if (!(field in fm)) continue;
|
|
294
314
|
const value = fm[field];
|
|
295
315
|
|
|
296
|
-
|
|
297
|
-
|
|
316
|
+
// An enum constraint must reject a wrong-typed value, not skip it. The
|
|
317
|
+
// earlier `typeof value === 'string'` guard silently passed any non-string
|
|
318
|
+
// (e.g. `discovery_mode: [standalone]` parsed as an array, or a number),
|
|
319
|
+
// letting a malformed field bypass validation entirely. Flag the type
|
|
320
|
+
// mismatch first; only run the membership check on an actual string.
|
|
321
|
+
if (Array.isArray(spec.enum)) {
|
|
322
|
+
if (typeof value !== 'string') {
|
|
323
|
+
errors.push(
|
|
324
|
+
`frontmatter.${field} must be a string (one of ${JSON.stringify(spec.enum)}), got ${Array.isArray(value) ? 'array' : typeof value}`,
|
|
325
|
+
);
|
|
326
|
+
} else if (!spec.enum.includes(value)) {
|
|
298
327
|
errors.push(
|
|
299
328
|
`frontmatter.${field} "${value}" is not one of ${JSON.stringify(spec.enum)}`,
|
|
300
329
|
);
|
|
@@ -433,10 +462,9 @@ function validateFrontmatter(fm, skillName) {
|
|
|
433
462
|
}
|
|
434
463
|
|
|
435
464
|
if ('last_threat_review' in fm) {
|
|
436
|
-
if (
|
|
437
|
-
!Number.isFinite(Date.parse(fm.last_threat_review + 'T00:00:00Z'))) {
|
|
465
|
+
if (!isStrictIsoCalendarDate(fm.last_threat_review)) {
|
|
438
466
|
errors.push(
|
|
439
|
-
`frontmatter.last_threat_review "${fm.last_threat_review}" is not a valid ISO date (YYYY-MM-DD). A structurally ISO but non-calendar value (e.g. 2026-13-99) is rejected so a malformed date cannot slip past the staleness gate.`,
|
|
467
|
+
`frontmatter.last_threat_review "${fm.last_threat_review}" is not a valid ISO date (YYYY-MM-DD). A structurally ISO but non-calendar value (e.g. 2026-13-99 or a rollover like 2026-02-30) is rejected so a malformed date cannot slip past the staleness gate.`,
|
|
440
468
|
);
|
|
441
469
|
} else {
|
|
442
470
|
// v0.13.0: Hard Rule #8 forcing function — refuse skills whose
|
|
@@ -1044,6 +1072,7 @@ module.exports = {
|
|
|
1044
1072
|
MIN_SECTION_BODY_WORDS,
|
|
1045
1073
|
validateFrontmatter,
|
|
1046
1074
|
schemaConstraintErrors,
|
|
1075
|
+
isStrictIsoCalendarDate,
|
|
1047
1076
|
FRONTMATTER_SCHEMA,
|
|
1048
1077
|
};
|
|
1049
1078
|
|
package/lib/playbook-runner.js
CHANGED
|
@@ -1579,9 +1579,34 @@ function capSummary(s, max = 240) {
|
|
|
1579
1579
|
|
|
1580
1580
|
function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
|
|
1581
1581
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
|
|
1582
|
+
// Remediation-path preconditions are evaluated through the SAME evalCondition
|
|
1583
|
+
// the analyze/close phases use, so the precondition context must expose the
|
|
1584
|
+
// SAME engine-computed roots those phases thread in — otherwise a precondition
|
|
1585
|
+
// that gates on `rwep`, `finding.severity`, `matched_cve.*`,
|
|
1586
|
+
// `blast_radius_score`, or `theater_verdict` resolves null and can NEVER be
|
|
1587
|
+
// satisfied, so its remediation path is only ever reachable as the priority
|
|
1588
|
+
// fallback (kernel's `any matched_cve.vector matches 'userns|bpf|ptrace|kptr'`
|
|
1589
|
+
// hardening-compensation path was permanently unsatisfiable for this reason).
|
|
1590
|
+
// agentSignals spread FIRST so the engine-computed values WIN on collision (a
|
|
1591
|
+
// poisoning signals.rwep can't override the engine value) — mirrors the
|
|
1592
|
+
// escalation (analyze) and feeds_into (close) contexts. finding.* merges the
|
|
1593
|
+
// host-AI-asserted descriptive keys UNDER the engine shape with the same
|
|
1594
|
+
// !Array.isArray guard used there.
|
|
1595
|
+
const govern = (playbook.phases && playbook.phases.govern) || {};
|
|
1596
|
+
let findingShape = {};
|
|
1597
|
+
try { findingShape = analyzeFindingShape(analyzeResult || {}); } catch { findingShape = {}; }
|
|
1598
|
+
const evalCtx = {
|
|
1599
|
+
...agentSignals,
|
|
1600
|
+
rwep: analyzeResult && analyzeResult.rwep ? analyzeResult.rwep.adjusted : undefined,
|
|
1601
|
+
blast_radius_score: analyzeResult ? analyzeResult.blast_radius_score : undefined,
|
|
1602
|
+
theater_verdict: analyzeResult && analyzeResult.compliance_theater_check ? analyzeResult.compliance_theater_check.verdict : undefined,
|
|
1603
|
+
compliance_theater_check: analyzeResult ? analyzeResult.compliance_theater_check : undefined,
|
|
1604
|
+
jurisdiction_obligations: govern.jurisdiction_obligations || [],
|
|
1605
|
+
matched_cve: (analyzeResult && analyzeResult.matched_cves) || [],
|
|
1606
|
+
analyze: analyzeResult,
|
|
1607
|
+
finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...findingShape },
|
|
1608
|
+
...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {}),
|
|
1609
|
+
};
|
|
1585
1610
|
const v = resolvedPhase(playbook, directiveId, 'validate');
|
|
1586
1611
|
|
|
1587
1612
|
// Pick the highest-priority remediation_path whose preconditions are all
|
|
@@ -4035,10 +4060,32 @@ function extractSubmissionForHash(sub) {
|
|
|
4035
4060
|
// canonicalStringify can serialize them.
|
|
4036
4061
|
const signals = {};
|
|
4037
4062
|
for (const [k, v] of Object.entries(sub.signals)) {
|
|
4063
|
+
// Underscore-prefixed signal keys are runner-internal / output-rendering
|
|
4064
|
+
// directives, NOT operator evidence: `_bundle_formats` (the CLI sets it
|
|
4065
|
+
// from `--format sarif|openvex|csaf`) only chooses which bundle close()
|
|
4066
|
+
// renders — the analyzed posture is identical. Hashing it broke the
|
|
4067
|
+
// "same evidence → same evidence_hash" contract: two runs over identical
|
|
4068
|
+
// evidence that differ only in `--format` produced different digests and
|
|
4069
|
+
// a self-contradicting attest diff. Exclude every `_`-prefixed signal key
|
|
4070
|
+
// (matches the file-wide "underscore = internal" convention). vex_filter /
|
|
4071
|
+
// vex_fixed are deliberately NOT excluded — they DROP CVEs from
|
|
4072
|
+
// matched_cves and change the finding, so they are posture-affecting
|
|
4073
|
+
// evidence; excluding them would make reattest blind to a VEX-driven
|
|
4074
|
+
// posture change (a real drift hidden behind an unchanged hash).
|
|
4075
|
+
if (k.startsWith('_')) continue;
|
|
4038
4076
|
if (v instanceof Set) signals[k] = Array.from(v).sort();
|
|
4039
4077
|
else signals[k] = v;
|
|
4040
4078
|
}
|
|
4041
|
-
|
|
4079
|
+
// Omit an empty signals bag rather than recording `signals: {}`. A
|
|
4080
|
+
// submission whose ONLY signal was a `_`-prefixed render directive
|
|
4081
|
+
// (`{ _bundle_formats: [...] }`, injected by `ci`/`run --format`) reduces to
|
|
4082
|
+
// {} after the filter above; the baseline no-format submission carries no
|
|
4083
|
+
// `signals` key at all. Recording `{}` vs omitting it would hash identical
|
|
4084
|
+
// evidence as `{signals:{}}` vs no-signals and reintroduce the exact
|
|
4085
|
+
// --format-driven evidence_hash / session-id drift this exclusion exists to
|
|
4086
|
+
// prevent. Only attach signals when at least one operator-meaningful key
|
|
4087
|
+
// survived.
|
|
4088
|
+
if (Object.keys(signals).length > 0) pick.signals = signals;
|
|
4042
4089
|
}
|
|
4043
4090
|
if (sub.precondition_checks && typeof sub.precondition_checks === 'object') {
|
|
4044
4091
|
pick.precondition_checks = sub.precondition_checks;
|
package/lib/prefetch.js
CHANGED
|
@@ -738,8 +738,10 @@ async function prefetch(options = {}) {
|
|
|
738
738
|
current.entries[entryKey(item.source, item.id)] = meta;
|
|
739
739
|
return current;
|
|
740
740
|
});
|
|
741
|
-
// Mirror the entry into the in-memory idx
|
|
742
|
-
//
|
|
741
|
+
// Mirror the entry into the in-memory idx snapshot so any
|
|
742
|
+
// later in-run freshness check sees this entry as fresh. The
|
|
743
|
+
// authoritative on-disk write already happened under the lock
|
|
744
|
+
// above; this is the in-memory copy only.
|
|
743
745
|
idx.entries[entryKey(item.source, item.id)] = meta;
|
|
744
746
|
} catch (lockErr) {
|
|
745
747
|
// Lock failure OR rename-inside-lock failure — unlink the staged
|
|
@@ -763,11 +765,17 @@ async function prefetch(options = {}) {
|
|
|
763
765
|
|
|
764
766
|
await Promise.all(jobPromises);
|
|
765
767
|
await queue.drain();
|
|
766
|
-
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
// run
|
|
770
|
-
|
|
768
|
+
// Each fetched entry was already persisted to the on-disk index under
|
|
769
|
+
// lock during the run (the per-entry withIndexLock above), so the final
|
|
770
|
+
// write only needs to stamp generated_at. Re-merging the whole
|
|
771
|
+
// start-of-run `idx` snapshot here would RESURRECT entries a concurrent
|
|
772
|
+
// run pruned between our snapshot and now — partially defeating the
|
|
773
|
+
// concurrency fix the per-entry lock provides. Bump generated_at on the
|
|
774
|
+
// CURRENT on-disk index under lock instead, touching nothing else.
|
|
775
|
+
await withIndexLock(opts.cacheDir, (current) => {
|
|
776
|
+
current.generated_at = new Date().toISOString();
|
|
777
|
+
return current;
|
|
778
|
+
});
|
|
771
779
|
|
|
772
780
|
// Sign the freshly-written _index.json with the Ed25519 private key
|
|
773
781
|
// (.keys/private.pem). The signature is a sidecar `_index.json.sig`;
|
|
@@ -87,9 +87,9 @@
|
|
|
87
87
|
"type": "array",
|
|
88
88
|
"items": {
|
|
89
89
|
"type": "string",
|
|
90
|
-
"pattern": "^D3-[A-
|
|
90
|
+
"pattern": "^D3[AF]?-[A-Z0-9]+(?:-[A-Z0-9]+)*$"
|
|
91
91
|
},
|
|
92
|
-
"description": "Optional. MITRE D3FEND defensive technique IDs the skill maps to. Each must resolve in data/d3fend-catalog.json."
|
|
92
|
+
"description": "Optional. MITRE D3FEND defensive technique IDs the skill maps to, across the D3-, D3A- (analysis), and D3F- (offensive/forensic) namespaces. Each must resolve in data/d3fend-catalog.json."
|
|
93
93
|
},
|
|
94
94
|
"dlp_refs": {
|
|
95
95
|
"type": "array",
|
package/lib/source-ghsa.js
CHANGED
|
@@ -246,8 +246,14 @@ async function fetchAdvisoryById(id, opts = {}) {
|
|
|
246
246
|
if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
|
|
247
247
|
return { ok: true, advisories: [match], source: "fixture" };
|
|
248
248
|
}
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
// Validate the FULL GHSA token shape (GHSA-xxxx-xxxx-xxxx) before building the
|
|
250
|
+
// request path, and encodeURIComponent the segment — symmetric with the CVE
|
|
251
|
+
// branch below. A prefix-only `/^GHSA-/i` left the remainder unconstrained, so
|
|
252
|
+
// an operator-supplied id like `GHSA-aaaa/../../meta` survived verbatim into
|
|
253
|
+
// `/advisories/...` as path-control segments; the strict shape now routes a
|
|
254
|
+
// malformed id to the unrecognized-id error envelope instead.
|
|
255
|
+
if (/^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$/i.test(id)) {
|
|
256
|
+
return fetchAdvisories({ ...opts, path: `/advisories/${encodeURIComponent(id.toLowerCase())}` });
|
|
251
257
|
}
|
|
252
258
|
if (/^CVE-\d{4}-\d+$/i.test(id)) {
|
|
253
259
|
return fetchAdvisories({ ...opts, path: `/advisories?cve_id=${encodeURIComponent(id.toUpperCase())}` });
|
package/lib/source-osv.js
CHANGED
|
@@ -542,6 +542,16 @@ function extractCvss(rec) {
|
|
|
542
542
|
// back from v4 -> v3 if v4 fails to compute.
|
|
543
543
|
const vectorsByVersion = new Map(); // version (number) -> vector string
|
|
544
544
|
let bareScore = null;
|
|
545
|
+
// Computed score for a single CVSS vector string: its trailing /N.N score if
|
|
546
|
+
// present, else the derived CVSS 3.x base score, else null (e.g. an
|
|
547
|
+
// uncomputable v4 vector). Used to keep the HIGHEST-scoring vector per major
|
|
548
|
+
// version regardless of severity[] array order.
|
|
549
|
+
const vectorScore = (vec) => {
|
|
550
|
+
const tail = vec.match(/\/(\d+(?:\.\d+)?)$/);
|
|
551
|
+
if (tail) { const t = parseFloat(tail[1]); if (t >= 0 && t <= 10) return t; }
|
|
552
|
+
if (/^CVSS:3\./.test(vec)) { const c = cvss3BaseScore(vec); if (c != null) return c; }
|
|
553
|
+
return null;
|
|
554
|
+
};
|
|
545
555
|
for (const s of sev) {
|
|
546
556
|
if (s == null) continue;
|
|
547
557
|
let raw = null;
|
|
@@ -554,15 +564,21 @@ function extractCvss(rec) {
|
|
|
554
564
|
// Bare numeric score (no vector prefix).
|
|
555
565
|
const num = parseFloat(v);
|
|
556
566
|
if (!Number.isNaN(num) && num >= 0 && num <= 10 && !v.includes("/")) {
|
|
557
|
-
|
|
567
|
+
// HIGHEST-wins, not first-wins: an OSV record legitimately carries
|
|
568
|
+
// multiple same-version severity entries (e.g. an NVD base score plus a
|
|
569
|
+
// CNA/vendor score) and the upstream array order is not guaranteed. A
|
|
570
|
+
// first-wins guard silently downgraded a 9.8 critical to a 5.3 medium
|
|
571
|
+
// purely because the lower score appeared earlier in severity[].
|
|
572
|
+
if (bareScore == null || num > bareScore) bareScore = num;
|
|
558
573
|
continue;
|
|
559
574
|
}
|
|
560
575
|
const m = v.match(/^CVSS:(\d+\.\d+)/);
|
|
561
576
|
if (!m) continue;
|
|
562
577
|
const ver = parseFloat(m[1]);
|
|
563
|
-
// Keep the
|
|
578
|
+
// Keep the HIGHEST-scoring vector within each major version (same rationale
|
|
579
|
+
// as the bare-score path above). Compare computed scores, not array order.
|
|
564
580
|
const prev = vectorsByVersion.get(ver);
|
|
565
|
-
if (!prev) vectorsByVersion.set(ver, v);
|
|
581
|
+
if (!prev || (vectorScore(v) ?? -1) > (vectorScore(prev) ?? -1)) vectorsByVersion.set(ver, v);
|
|
566
582
|
}
|
|
567
583
|
// Try versions in descending order. CVSS 4.0 derivation is not yet
|
|
568
584
|
// implemented here — if v4 was the highest but can't be computed, walk
|
package/lib/ttp-mapper.js
CHANGED
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
* and surfaces gaps where controls fail to cover attacker techniques.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
9
|
+
|
|
8
10
|
function map(controlId, gapCatalog) {
|
|
11
|
+
// hasOwnProperty guard: a bare gapCatalog[controlId] dereferences inherited
|
|
12
|
+
// Object.prototype members, so controlId='__proto__' / 'toString' /
|
|
13
|
+
// 'constructor' returned found:true for a key that is not a real catalog
|
|
14
|
+
// entry. Only own enumerable keys are catalog controls.
|
|
15
|
+
if (!gapCatalog || !hasOwn(gapCatalog, controlId)) {
|
|
16
|
+
return { control_id: controlId, found: false, message: 'Control not in gap catalog' };
|
|
17
|
+
}
|
|
9
18
|
const entry = gapCatalog[controlId];
|
|
10
19
|
if (!entry) return { control_id: controlId, found: false, message: 'Control not in gap catalog' };
|
|
11
20
|
return {
|
|
@@ -47,6 +56,12 @@ function coverage(frameworkId, ttpId, gapCatalog, atlasCatalog) {
|
|
|
47
56
|
return { ttp_id: ttpId, found: false, error: 'frameworkId required', partially_covered_by: null, not_covered_by: null };
|
|
48
57
|
}
|
|
49
58
|
|
|
59
|
+
// hasOwnProperty guard: ttpId='__proto__' / 'toString' / 'constructor'
|
|
60
|
+
// would otherwise resolve to an inherited Object.prototype member and be
|
|
61
|
+
// treated as a real ATLAS technique. Only own keys are catalog techniques.
|
|
62
|
+
if (!atlasCatalog || !hasOwn(atlasCatalog, ttpId)) {
|
|
63
|
+
return { ttp_id: ttpId, found: false };
|
|
64
|
+
}
|
|
50
65
|
const ttp = atlasCatalog[ttpId];
|
|
51
66
|
if (!ttp) return { ttp_id: ttpId, found: false };
|
|
52
67
|
|
package/lib/upstream-check.js
CHANGED
|
@@ -47,7 +47,15 @@ async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName =
|
|
|
47
47
|
const fs = require("fs");
|
|
48
48
|
const fixture = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
|
|
49
49
|
const version = fixture["dist-tags"]?.latest || fixture.version;
|
|
50
|
-
|
|
50
|
+
// Same guard the network branch applies: a fixture with neither
|
|
51
|
+
// dist-tags.latest nor a top-level version yields no freshness signal.
|
|
52
|
+
// Returning ok:true with version:undefined would let an undefined
|
|
53
|
+
// version leak into buildFreshnessReport's semver compare and the
|
|
54
|
+
// operator-facing hint. Degrade to an explicit offline refusal.
|
|
55
|
+
if (!version) {
|
|
56
|
+
return { ok: false, error: "fixture missing dist-tags.latest / version", source: "offline" };
|
|
57
|
+
}
|
|
58
|
+
const published = fixture.time?.[version] || fixture.time?.modified || null;
|
|
51
59
|
return { ok: true, version, published_at: published, source: "fixture" };
|
|
52
60
|
} catch (e) {
|
|
53
61
|
return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
|
|
@@ -120,8 +128,14 @@ function buildFreshnessReport({ localVersion, registry, localManifest }) {
|
|
|
120
128
|
};
|
|
121
129
|
}
|
|
122
130
|
const cmp = semverCmp(localVersion, registry.version);
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
// Parse the publish timestamp once and validate it. A non-empty but
|
|
132
|
+
// unparseable date string (e.g. a malformed registry `time` value) makes
|
|
133
|
+
// `new Date(...).getTime()` return NaN, which would otherwise propagate
|
|
134
|
+
// NaN into days_since_latest_publish. Degrade an unparseable date to the
|
|
135
|
+
// explicit null (signal-absent) branch instead of emitting NaN.
|
|
136
|
+
const publishedTs = registry.published_at ? new Date(registry.published_at).getTime() : NaN;
|
|
137
|
+
const daysBehind = Number.isFinite(publishedTs)
|
|
138
|
+
? Math.max(0, Math.floor((Date.now() - publishedTs) / (24 * 3600 * 1000)))
|
|
125
139
|
: null;
|
|
126
140
|
// Manifest's last_threat_review (per-skill) — surface the most stale.
|
|
127
141
|
let oldestReview = null;
|
|
@@ -384,8 +384,16 @@ function main() {
|
|
|
384
384
|
const schema = readJson(SCHEMA_PATH);
|
|
385
385
|
const catalog = readJson(CATALOG_PATH);
|
|
386
386
|
const lessons = readJson(LESSONS_PATH);
|
|
387
|
-
|
|
388
|
-
|
|
387
|
+
// All five reference catalogs default to null when absent so the
|
|
388
|
+
// "cannot validate — catalog absent" branch in additionalChecks fires
|
|
389
|
+
// uniformly for every ref family. atlas/cwe previously defaulted to {},
|
|
390
|
+
// which built an EMPTY Set (never null) — so an absent atlas/cwe catalog
|
|
391
|
+
// silently FLAGGED every ref as unresolved (false positive) instead of
|
|
392
|
+
// surfacing the catalog-absent warning, the asymmetric opposite of how
|
|
393
|
+
// attack/d3fend/framework behaved. Uniform null makes the absence
|
|
394
|
+
// observable rather than misclassified.
|
|
395
|
+
const atlas = fs.existsSync(ATLAS_PATH) ? readJson(ATLAS_PATH) : null;
|
|
396
|
+
const cwe = fs.existsSync(CWE_PATH) ? readJson(CWE_PATH) : null;
|
|
389
397
|
const attack = fs.existsSync(ATTACK_PATH) ? readJson(ATTACK_PATH) : null;
|
|
390
398
|
const d3fend = fs.existsSync(D3FEND_PATH) ? readJson(D3FEND_PATH) : null;
|
|
391
399
|
const frameworks = fs.existsSync(FRAMEWORK_GAPS_PATH)
|
|
@@ -393,8 +401,12 @@ function main() {
|
|
|
393
401
|
: null;
|
|
394
402
|
|
|
395
403
|
const ctx = {
|
|
396
|
-
atlasKeys:
|
|
397
|
-
|
|
404
|
+
atlasKeys: atlas
|
|
405
|
+
? new Set(Object.keys(atlas).filter((k) => !k.startsWith('_')))
|
|
406
|
+
: null,
|
|
407
|
+
cweKeys: cwe
|
|
408
|
+
? new Set(Object.keys(cwe).filter((k) => !k.startsWith('_')))
|
|
409
|
+
: null,
|
|
398
410
|
attackKeys: attack
|
|
399
411
|
? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
|
|
400
412
|
: null,
|
package/lib/validate-package.js
CHANGED
|
@@ -150,8 +150,15 @@ function main() {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
// Size budget
|
|
154
|
-
|
|
153
|
+
// Size budget. An absent/non-numeric `size` field is NOT a pass — it means
|
|
154
|
+
// the gate cannot evaluate the budget at all. Fail loudly instead of
|
|
155
|
+
// silently skipping (the absent-field-fails-open class, symmetric with the
|
|
156
|
+
// license_sha256 / vendored_sha256 guards in validate-vendor.js): a future
|
|
157
|
+
// npm-pack output shape change that drops or renames `size` must not turn the
|
|
158
|
+
// size budget into a no-op.
|
|
159
|
+
if (typeof packInfo.size !== "number") {
|
|
160
|
+
issues.push(`npm pack output missing numeric size — cannot enforce size budget`);
|
|
161
|
+
} else if (packInfo.size > SIZE_BUDGET_BYTES) {
|
|
155
162
|
issues.push(`tarball size ${(packInfo.size / 1024 / 1024).toFixed(2)} MB exceeds budget ${(SIZE_BUDGET_BYTES / 1024 / 1024).toFixed(0)} MB`);
|
|
156
163
|
}
|
|
157
164
|
|
|
@@ -79,6 +79,21 @@ const fs = require('node:fs');
|
|
|
79
79
|
const path = require('node:path');
|
|
80
80
|
const process = require('node:process');
|
|
81
81
|
const { safeExit } = require('./exit-codes');
|
|
82
|
+
// _evalCondition is the SAME parser the runner uses at analyze/validate/close
|
|
83
|
+
// time. Exercising every escalation / feeds_into / remediation-precondition
|
|
84
|
+
// condition through the real evaluator lets the validator reject a condition the
|
|
85
|
+
// engine cannot parse (condition_unparsed) — a prose / bare-token /
|
|
86
|
+
// unimplemented-syntax condition that would silently return false for every
|
|
87
|
+
// input and disable the escalation it gates. No cycle: playbook-runner does not
|
|
88
|
+
// require this validator. Loaded defensively: the engine carries a dependency
|
|
89
|
+
// closure (scoring / cross-ref-api / id-validation) that a stripped test mirror
|
|
90
|
+
// staging only the validator + schema does not copy, so a hard top-level require
|
|
91
|
+
// would crash the validator there. In the real repo and the shipped tarball the
|
|
92
|
+
// engine is always present, so the parse-gate always runs; when it is genuinely
|
|
93
|
+
// unresolvable the gate surfaces a WARNING (never a silent error-skip — see
|
|
94
|
+
// checkCrossRefs) so the degradation is observable rather than a false pass.
|
|
95
|
+
let _evalCondition = null;
|
|
96
|
+
try { ({ _evalCondition } = require('./playbook-runner')); } catch { /* warned at the gate */ }
|
|
82
97
|
|
|
83
98
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
84
99
|
const SCHEMA_PATH = path.join(REPO_ROOT, 'lib', 'schemas', 'playbook.schema.json');
|
|
@@ -129,6 +144,25 @@ function typeOf(value) {
|
|
|
129
144
|
return typeof value;
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
// Strict ISO calendar-date check for format:date fields. The shape regex alone
|
|
148
|
+
// accepts impossible dates — `new Date('2026-02-30T00:00:00Z')` does NOT throw,
|
|
149
|
+
// it silently rolls over to March 2 and reports a valid getTime() — so a
|
|
150
|
+
// malformed _meta.last_threat_review or changelog `date` (2026-02-30,
|
|
151
|
+
// 2026-13-40) would pass with only the YYYY-MM-DD shape test. Require the
|
|
152
|
+
// parsed Y-M-D to round-trip back to the input components, mirroring
|
|
153
|
+
// parseIsoDateStrict in lib/validate-catalog-meta.js.
|
|
154
|
+
function isStrictIsoDate(value) {
|
|
155
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
156
|
+
const d = new Date(value + 'T00:00:00Z');
|
|
157
|
+
if (Number.isNaN(d.getTime())) return false;
|
|
158
|
+
const [y, m, day] = value.split('-').map(Number);
|
|
159
|
+
return (
|
|
160
|
+
d.getUTCFullYear() === y &&
|
|
161
|
+
d.getUTCMonth() + 1 === m &&
|
|
162
|
+
d.getUTCDate() === day
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
132
166
|
function typeMatches(value, expected) {
|
|
133
167
|
if (Array.isArray(expected)) return expected.some((t) => typeMatches(value, t));
|
|
134
168
|
const actual = typeOf(value);
|
|
@@ -185,7 +219,7 @@ function validate(value, schema, schemaName, pathStr) {
|
|
|
185
219
|
}
|
|
186
220
|
}
|
|
187
221
|
if (schema.format === 'date') {
|
|
188
|
-
if (
|
|
222
|
+
if (!isStrictIsoDate(value)) {
|
|
189
223
|
err(`${here}: value ${JSON.stringify(value)} is not an ISO date (YYYY-MM-DD)`);
|
|
190
224
|
}
|
|
191
225
|
}
|
|
@@ -531,6 +565,68 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
531
565
|
}
|
|
532
566
|
}
|
|
533
567
|
|
|
568
|
+
// Condition PARSEABILITY gate. The path-root check above only catches a
|
|
569
|
+
// dotted path rooted at an unavailable phase; the broader and far more common
|
|
570
|
+
// dead-condition shape is a condition the evaluator cannot parse AT ALL —
|
|
571
|
+
// free-text prose ("A single compromised identity can rewrite the trail"), a
|
|
572
|
+
// bare-truthiness token without `== true`, an unimplemented keyword
|
|
573
|
+
// (`<tok> fired`, `not in`, `is`), or an apostrophe-bearing token that fails
|
|
574
|
+
// the LHS pattern. Every such condition falls through evalCondition to a
|
|
575
|
+
// silent `false` for every input, so the raise_severity / trigger_playbook /
|
|
576
|
+
// remediation-precondition it gates never fires — and nothing flagged it,
|
|
577
|
+
// because the validator never ran the actual evaluator. Run each condition in
|
|
578
|
+
// the THREE locations the runner threads through evalCondition (analyze
|
|
579
|
+
// escalation_criteria, _meta.feeds_into, validate remediation_paths
|
|
580
|
+
// preconditions) and hard-fail on condition_unparsed. Scoped to exactly those
|
|
581
|
+
// three: scoring_rubric entries are descriptive blast-level prose (never
|
|
582
|
+
// evaluated — the score comes from agentSignals.blast_radius_score) and
|
|
583
|
+
// regression_trigger conditions are consumed by computeRegressionNextRun's own
|
|
584
|
+
// cadence/event parser, so neither is in scope here. Gate ONLY on
|
|
585
|
+
// condition_unparsed — a genuine PARSE failure is input-independent;
|
|
586
|
+
// condition_path_unresolved / condition_type_mismatch are runtime data
|
|
587
|
+
// diagnostics that would false-positive against this empty validation context
|
|
588
|
+
// (a dotted path legitimately resolves null when no run has populated it).
|
|
589
|
+
if (!_evalCondition) {
|
|
590
|
+
// Engine module not resolvable from this validator location (a stripped
|
|
591
|
+
// test mirror). Surface it as a WARNING so the skip is observable — in the
|
|
592
|
+
// real repo + shipped tarball the engine always loads and this never fires.
|
|
593
|
+
warn(
|
|
594
|
+
'escalation/feeds_into/precondition parse-gate skipped: lib/playbook-runner.js (the condition evaluator) was not resolvable from this validator location, so condition parseability was not checked',
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const conditionParses = (cond) => {
|
|
598
|
+
if (typeof cond !== 'string' || !cond.trim()) return true;
|
|
599
|
+
if (!_evalCondition) return true; // engine unavailable — warned above, never a silent error-skip
|
|
600
|
+
const runErrors = [];
|
|
601
|
+
try { _evalCondition(cond, { _runErrors: runErrors }, { _runErrors: runErrors }); }
|
|
602
|
+
catch { return true; } // an evaluator throw is a separate concern, not "unparseable"
|
|
603
|
+
return !runErrors.some((e) => e && e.kind === 'condition_unparsed');
|
|
604
|
+
};
|
|
605
|
+
for (const [i, ec] of (((phases.analyze || {}).escalation_criteria) || []).entries()) {
|
|
606
|
+
if (ec && typeof ec === 'object' && !conditionParses(ec.condition)) {
|
|
607
|
+
err(
|
|
608
|
+
`phases.analyze.escalation_criteria[${i}].condition: not parseable by the runner's evalCondition (${JSON.stringify(String(ec.condition).slice(0, 80))}) — it returns false for every input, so action "${ec.action || '?'}" never fires. Rewrite into the mini-language (<indicator-id> == true [AND/OR …], engine roots rwep/blast_radius_score/matched_cve/finding.*, membership IN [...], quantifier any/all, matches '…').`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
for (const [i, f] of (meta.feeds_into || []).entries()) {
|
|
613
|
+
if (f && typeof f === 'object' && !conditionParses(f.condition)) {
|
|
614
|
+
err(
|
|
615
|
+
`_meta.feeds_into[${i}].condition: not parseable by the runner's evalCondition (${JSON.stringify(String(f.condition).slice(0, 80))}) — the chain to "${f.playbook_id || '?'}" never fires. Rewrite into the mini-language.`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const [i, rp] of (validatePhase.remediation_paths || []).entries()) {
|
|
620
|
+
if (!rp || typeof rp !== 'object' || !Array.isArray(rp.preconditions)) continue;
|
|
621
|
+
for (const [j, pc] of rp.preconditions.entries()) {
|
|
622
|
+
if (!conditionParses(pc)) {
|
|
623
|
+
err(
|
|
624
|
+
`phases.validate.remediation_paths[${i}] (${rp.id || 'unknown'}).preconditions[${j}]: not parseable by the runner's evalCondition (${JSON.stringify(String(pc).slice(0, 80))}) — it is never satisfiable, so the path can only be selected as the priority fallback. Rewrite into the mini-language (bare token → "<token> == true", prose gate → an operator-signalable "<snake_token> == true").`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
534
630
|
// Air-gap completeness. When _meta.air_gap_mode is true the runner refuses
|
|
535
631
|
// to touch the network, so every artifact whose source is a network call
|
|
536
632
|
// (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry a
|
|
@@ -669,6 +765,21 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
669
765
|
// the source is the base phase or a directive override.
|
|
670
766
|
function checkRwepThreshold(rwepObj, pathPrefix) {
|
|
671
767
|
const rwep = rwepObj || {};
|
|
768
|
+
// Range check is PER-KEY and independent of the others. A directive's
|
|
769
|
+
// phase_overrides.direct.rwep_threshold is a partial fragment the runner
|
|
770
|
+
// deep-merges, so it may carry only one of the three keys — gating the
|
|
771
|
+
// whole check on all-three-present let an out-of-range single-key override
|
|
772
|
+
// (e.g. {escalate: 150} or {close: -5}) ship unchecked and reach the
|
|
773
|
+
// runner. Validate each present numeric key on its own.
|
|
774
|
+
for (const k of ['close', 'monitor', 'escalate']) {
|
|
775
|
+
const v = rwep[k];
|
|
776
|
+
if (typeof v === 'number' && (v < 0 || v > 100)) {
|
|
777
|
+
err(`${pathPrefix}.${k}: ${v} outside 0..100`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// Ordering is only meaningful when the full triple is present (a partial
|
|
781
|
+
// override inherits the missing edges from the base phase, so close <=
|
|
782
|
+
// monitor <= escalate can't be evaluated on the fragment alone).
|
|
672
783
|
if (
|
|
673
784
|
typeof rwep.close === 'number' &&
|
|
674
785
|
typeof rwep.monitor === 'number' &&
|
|
@@ -679,15 +790,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
679
790
|
`${pathPrefix}: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
|
|
680
791
|
);
|
|
681
792
|
}
|
|
682
|
-
for (const [k, v] of [
|
|
683
|
-
['close', rwep.close],
|
|
684
|
-
['monitor', rwep.monitor],
|
|
685
|
-
['escalate', rwep.escalate],
|
|
686
|
-
]) {
|
|
687
|
-
if (v < 0 || v > 100) {
|
|
688
|
-
err(`${pathPrefix}.${k}: ${v} outside 0..100`);
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
793
|
}
|
|
692
794
|
}
|
|
693
795
|
|
package/lib/validate-vendor.js
CHANGED
|
@@ -111,17 +111,23 @@ function main() {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
// On-disk inventory cross-check (BJS-04): the loop above is one-directional
|
|
114
|
-
// (manifest → disk). A
|
|
115
|
-
// _PROVENANCE.json would ship in the tarball and be require()'d
|
|
116
|
-
// while NOTHING verifies its integrity — an unregistered or
|
|
117
|
-
// vendored module.
|
|
114
|
+
// (manifest → disk). A loadable JS module PRESENT in vendor/blamejs/ but
|
|
115
|
+
// ABSENT from _PROVENANCE.json would ship in the tarball and be require()'d
|
|
116
|
+
// by lib code while NOTHING verifies its integrity — an unregistered or
|
|
117
|
+
// smuggled-in vendored module. Node executes `.js`, `.cjs`, and `.mjs` as
|
|
118
|
+
// code, so a `.cjs`/`.mjs` smuggled module carries the same arbitrary-code
|
|
119
|
+
// risk as a `.js` one — checking only `.js` let a renamed extension slip the
|
|
120
|
+
// gate. `.json`/`.node` are deliberately NOT swept here: `_PROVENANCE.json`
|
|
121
|
+
// itself lives in this directory and is the manifest, not a registered
|
|
122
|
+
// vendored module, so sweeping `.json` would false-positive on the manifest.
|
|
123
|
+
const LOADABLE_MODULE = /\.(c|m)?js$/;
|
|
118
124
|
const registered = new Set(
|
|
119
125
|
Object.values(prov.files || {}).map((info) => path.basename(info.vendored_path))
|
|
120
126
|
);
|
|
121
127
|
const vendorDir = path.join(ROOT, "vendor", "blamejs");
|
|
122
128
|
try {
|
|
123
129
|
for (const f of fs.readdirSync(vendorDir)) {
|
|
124
|
-
if (!
|
|
130
|
+
if (!LOADABLE_MODULE.test(f)) continue;
|
|
125
131
|
if (!registered.has(f)) {
|
|
126
132
|
issues.push(`unregistered vendored file: vendor/blamejs/${f} is on disk but absent from _PROVENANCE.json — its integrity is not verified`);
|
|
127
133
|
}
|