@blamejs/exceptd-skills 0.18.11 → 0.18.13
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 +12 -5
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +10 -1
- package/lib/collectors/cred-stores.js +18 -9
- package/lib/collectors/secrets.js +4 -2
- package/lib/cve-regression-watcher.js +5 -1
- package/lib/framework-gap.js +25 -14
- package/lib/job-queue.js +9 -2
- package/lib/lint-skills.js +34 -5
- package/lib/playbook-runner.js +65 -2
- package/lib/prefetch.js +15 -7
- package/lib/rfc-cli.js +7 -2
- package/lib/schemas/skill-frontmatter.schema.json +2 -2
- package/lib/scoring.js +40 -7
- 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 +35 -10
- package/lib/validate-vendor.js +11 -5
- package/lib/verify.js +35 -34
- package/lib/xml-tokenizer.js +36 -8
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +8 -0
- package/orchestrator/scanner.js +20 -0
- package/package.json +1 -1
- package/sbom.cdx.json +87 -87
- package/scripts/builders/cwe-chains.js +5 -2
- package/scripts/builders/summary-cards.js +12 -4
- package/scripts/check-codebase-patterns-currency.js +1 -0
- package/scripts/check-codebase-patterns.js +42 -0
- package/scripts/check-test-subjects.js +27 -6
- package/scripts/predeploy.js +19 -8
- package/scripts/refresh-upstream-catalogs.js +20 -3
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.13 — 2026-06-22
|
|
4
|
+
|
|
5
|
+
A correctness + robustness pass across the upstream data parsers, the validators, and the prefetch/feed paths.
|
|
6
|
+
|
|
7
|
+
The OSV advisory importer keeps the HIGHEST CVSS vector and score when a record carries multiple same-version severity entries (e.g. an NVD base score alongside a CNA/vendor score), instead of whichever appeared first in the array — a 9.8 critical no longer imports as a 5.3 medium purely because of upstream array order, which previously also flipped the derived KEV-pending / active-exploitation / severity-word fields. The RSS/Atom feed tokenizer strips HTML in a single linear pass, so a malformed or hostile feed body with many unclosed `<` can no longer stall the refresh worker (the previous regex was quadratic on attacker-controlled text). An Atom `<link>` carrying element text is now selected by its `rel` the same way the attribute form is, so a non-`alternate` link cannot override the canonical `alternate`. `refresh --advisory <ghsa-id>` validates the full `GHSA-xxxx-xxxx-xxxx` token shape and URL-encodes it before building the request path.
|
|
8
|
+
|
|
9
|
+
The playbook validator range-checks a partial `phase_overrides.direct.rwep_threshold` override and rejects an impossible calendar date (`2026-02-30`); the CVE-catalog validator reports an absent reference catalog as unvalidatable for ALL five reference families instead of flagging every `atlas_ref`/`cwe_ref` as unresolved; the RFC validator skips non-IETF references (CSAF, ISO) rather than reporting them as permanent drift; the version-pin validator no longer probes the two upstreams that publish no GitHub releases (reported as tracked elsewhere); the vendor inventory cross-check covers `.cjs`/`.mjs`/`.json`, not only `.js`; the package size-budget gate fails closed when `npm pack` omits the size field; and the disputed-CVE check reports the real local status. The skill linter flags a wrong-typed enum value and rejects an impossible `last_threat_review`, and the `d3fend_refs` frontmatter pattern accepts the `D3A-`/`D3F-` id families. `upstream-check` reports a clear error rather than `ok:true` with an undefined version when a fixture lacks dist-tags, and a non-parseable publish date yields a clear value instead of `NaN`. The prefetch index no longer resurrects entries a concurrent run pruned, `ttp-mapper` ignores inherited object keys, and the EPSS lookup returns not-found for an absent CVE instead of the first row.
|
|
10
|
+
|
|
11
|
+
## 0.18.12 — 2026-06-22
|
|
12
|
+
|
|
13
|
+
A correctness pass across the CLI, the engine, scoring, the collectors, framework-gap reporting, signature verification, and the CWE-chain index.
|
|
14
|
+
|
|
15
|
+
`ci`/`run --evidence-dir` now refuses a `<playbook>.json` entry that is not a JSON object (an array, scalar, or null) with the same object-shape error the single-file and stdin paths use, instead of binding it as empty evidence and reporting a clean `not_detected` PASS at exit 0 — a mis-shaped per-playbook submission no longer produces a false-clean gate result.
|
|
16
|
+
|
|
17
|
+
`verifyManifestSignature` consults the `keys/EXPECTED_FINGERPRINT` key pin before the missing-signature path, so a swapped `keys/public.pem` whose `manifest_signature` was stripped is rejected through the library API rather than treated as a benign legacy state. Escalation and `feeds_into` ordering comparisons against a duration literal — e.g. the kernel playbook's `reboot_window > 24h` raise-severity chain — normalize both sides to hours and compare numerically instead of lexicographically (so a 48-hour window escalates and a 6-hour window does not); an ordering comparison that degrades to two non-numeric, non-duration strings now surfaces a `condition_type_mismatch` diagnostic instead of evaluating silently.
|
|
18
|
+
|
|
19
|
+
`compare()`'s factor explanation lists the reboot (+5) driver whenever a reboot is required, regardless of live-patch availability, matching the score it actually computes; and a post-weight RWEP block that stores `active_exploitation` as a status string is read as a post-weight block (its weighted factors, including the AI factor, are no longer dropped or mis-flagged as a mixed shape).
|
|
20
|
+
|
|
21
|
+
The secrets collector's `ssh-key-bad-perms` posture and the credential-store AWS doc-fixture demotion no longer false-positive on checked-in test-path fixtures or a duplicate profile name. A single-framework `framework-gap` report scopes its theater-risk list and matching-gap count to the requested framework instead of leaking controls from frameworks the operator did not ask about. `rfc --check` no longer falsely matches an unrelated title when the claimed title repeats a token. The CWE-chains index excludes auto-imported draft CVEs, matching the by-CVE half's curated-truth invariant. A null or non-object MCP server entry (config scan) or dispatch finding is skipped with a clear marker rather than dropping the file's other findings or throwing an opaque error.
|
|
22
|
+
|
|
3
23
|
## 0.18.11 — 2026-06-22
|
|
4
24
|
|
|
5
25
|
Regenerates the CycloneDX SBOM (`sbom.cdx.json`) so its recorded hash for `CHANGELOG.md` matches the shipped file.
|
package/bin/exceptd.js
CHANGED
|
@@ -4339,12 +4339,19 @@ function readEvidenceDir(dir, verb) {
|
|
|
4339
4339
|
extra: { entry: f, resolved_to: realEntry },
|
|
4340
4340
|
};
|
|
4341
4341
|
}
|
|
4342
|
-
|
|
4342
|
+
// Apply the SAME object-shape guard the single-file / stdin path uses
|
|
4343
|
+
// (asEvidenceObject): a `<pb>.json` that parses to an array, scalar, or
|
|
4344
|
+
// null is not a valid evidence document. Without this an mis-shaped entry
|
|
4345
|
+
// was bound verbatim and ran downstream as empty evidence, yielding a
|
|
4346
|
+
// false-clean `not_detected` PASS at exit 0 — the exact hole the
|
|
4347
|
+
// single-file guard closes.
|
|
4348
|
+
bundle[pbId] = asEvidenceObject(JSON.parse(raw));
|
|
4343
4349
|
} catch (e) {
|
|
4344
|
-
// A
|
|
4345
|
-
// it with the entry name
|
|
4346
|
-
//
|
|
4347
|
-
|
|
4350
|
+
// A JSON parse error or the asEvidenceObject shape refusal lands here;
|
|
4351
|
+
// surface it with the entry name so the operator sees the real reason
|
|
4352
|
+
// (e.g. "evidence must be a JSON object"). The explicit symlink/junction
|
|
4353
|
+
// refusals above return directly and never reach this catch.
|
|
4354
|
+
return { ok: false, error: `${verb}: --evidence-dir entry ${f}: ${e.message}`, extra: { entry: f } };
|
|
4348
4355
|
} finally {
|
|
4349
4356
|
try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
|
|
4350
4357
|
}
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-06-
|
|
3
|
+
"generated_at": "2026-06-22T13:09:57.368Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 64,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "04dc4d4026147da386256d462aec657b1746e9539f8d8dc3ed0ad522f437d765",
|
|
8
8
|
"README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
|
|
9
9
|
"data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
|
|
10
10
|
"data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
|
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,
|
|
@@ -57,7 +57,7 @@ function fileExists(full) {
|
|
|
57
57
|
// AWS credentials INI: any [profile] block carrying
|
|
58
58
|
// `aws_access_key_id` AND no `sso_session` / `credential_process`.
|
|
59
59
|
function parseAwsCredentials(content) {
|
|
60
|
-
if (!content) return { staticProfiles: [], federatedProfiles: [] };
|
|
60
|
+
if (!content) return { staticProfiles: [], federatedProfiles: [], staticKeys: {} };
|
|
61
61
|
const lines = content.split(/\r?\n/);
|
|
62
62
|
const profiles = {};
|
|
63
63
|
let current = null;
|
|
@@ -77,13 +77,21 @@ function parseAwsCredentials(content) {
|
|
|
77
77
|
}
|
|
78
78
|
const staticProfiles = [];
|
|
79
79
|
const federatedProfiles = [];
|
|
80
|
+
// Per-static-profile aws_access_key_id, so doc-fixture demotion can key off
|
|
81
|
+
// the exact parsed value instead of re-finding the first name-matching block.
|
|
82
|
+
// A duplicate profile name resolves to the LAST occurrence's keys here, which
|
|
83
|
+
// is the same precedence the AWS SDK applies.
|
|
84
|
+
const staticKeys = {};
|
|
80
85
|
for (const [name, kv] of Object.entries(profiles)) {
|
|
81
86
|
const hasKey = !!kv["aws_access_key_id"];
|
|
82
87
|
const hasFederation = !!(kv["sso_session"] || kv["credential_process"] || kv["role_arn"]);
|
|
83
|
-
if (hasKey && !hasFederation)
|
|
88
|
+
if (hasKey && !hasFederation) {
|
|
89
|
+
staticProfiles.push(name);
|
|
90
|
+
staticKeys[name] = kv["aws_access_key_id"];
|
|
91
|
+
}
|
|
84
92
|
if (hasFederation) federatedProfiles.push(name);
|
|
85
93
|
}
|
|
86
|
-
return { staticProfiles, federatedProfiles };
|
|
94
|
+
return { staticProfiles, federatedProfiles, staticKeys };
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
// kubeconfig: users[].user.token field present (non-empty) with no
|
|
@@ -247,12 +255,13 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
247
255
|
// unsatisfied, which is the honest outcome.
|
|
248
256
|
const AWS_DOC_FIXTURE_KEY = "AKIAIOSFODNN7EXAMPLE";
|
|
249
257
|
const realAwsProfiles = awsCredsParsed.staticProfiles.filter(p => {
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
// Demote off this profile's EXACT parsed key value (FP[0]) and its name
|
|
259
|
+
// (FP[2]). Keying off the parsed value — not the first raw block whose
|
|
260
|
+
// name matches — means a duplicate profile name whose first occurrence
|
|
261
|
+
// holds the doc-fixture key cannot demote the later real key under the
|
|
262
|
+
// same name (the parser resolves the live last-occurrence value).
|
|
263
|
+
const keyVal = awsCredsParsed.staticKeys[p];
|
|
264
|
+
if (keyVal === AWS_DOC_FIXTURE_KEY) return false; // FP[0]
|
|
256
265
|
if (/^breakglass-/i.test(p) || /^break-glass-/i.test(p)) return false; // FP[2]
|
|
257
266
|
return true;
|
|
258
267
|
});
|
|
@@ -495,8 +495,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
495
495
|
// any private-key file with mode != 0600
|
|
496
496
|
// The collector scope is the cwd; ~/.ssh enumeration is outside this
|
|
497
497
|
// walk root. Within cwd, flag any discovered private key whose mode
|
|
498
|
-
// is anything other than 0600 (strict).
|
|
499
|
-
|
|
498
|
+
// is anything other than 0600 (strict). Use the test-path-filtered set
|
|
499
|
+
// (prodSshPrivateKeys) — matching ssh-private-key-block — so a fixture
|
|
500
|
+
// key checked in under a test/ path doesn't raise a bad-perms posture.
|
|
501
|
+
const sshKeyPostures = process.platform === "win32" ? [] : prodSshPrivateKeys.map(f => ({ file: f.rel, ...statPosture(f.full) }));
|
|
500
502
|
signal_overrides["ssh-key-bad-perms"] = sshKeyPostures.some(p => p.error == null && p.mode !== 0o600) ? "hit" : "miss";
|
|
501
503
|
|
|
502
504
|
// Per-indicator file locations for every indicator flipped to "hit", so
|
|
@@ -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/framework-gap.js
CHANGED
|
@@ -211,6 +211,24 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, o
|
|
|
211
211
|
};
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
// Scope the report to what the operator actually requested. With an explicit
|
|
215
|
+
// framework filter, only gaps that survived the per-framework filter
|
|
216
|
+
// (frameworkResults[*].gaps) belong in the report; with `all`, every
|
|
217
|
+
// scenario-relevant gap does. `seen` is the de-duplicated set of surviving
|
|
218
|
+
// gap keys (a single gap can match multiple requested frameworks). Both
|
|
219
|
+
// theater_risks and the matching count derive from it so the per-framework
|
|
220
|
+
// body, the theater-risk list, and the summary footer all agree.
|
|
221
|
+
let scopedGaps;
|
|
222
|
+
if (opts.allFrameworks) {
|
|
223
|
+
scopedGaps = relevantGaps;
|
|
224
|
+
} else {
|
|
225
|
+
const seen = new Set();
|
|
226
|
+
for (const id of frameworkIds) {
|
|
227
|
+
for (const g of frameworkResults[id]?.gaps ?? []) seen.add(g.id);
|
|
228
|
+
}
|
|
229
|
+
scopedGaps = relevantGaps.filter(([key]) => seen.has(key));
|
|
230
|
+
}
|
|
231
|
+
|
|
214
232
|
// Cycle 20 A P1 (v0.12.40): pre-fix this filtered on `theater_pattern`
|
|
215
233
|
// (a legacy field) but the v0.12.29 backfill added a structured
|
|
216
234
|
// `theater_test` block on all 118 entries while leaving most without
|
|
@@ -220,7 +238,11 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, o
|
|
|
220
238
|
// the legacy field. Now: an entry is theater-risk if it's open AND
|
|
221
239
|
// carries EITHER `theater_test` OR `theater_pattern`. Footer + badge
|
|
222
240
|
// count agree.
|
|
223
|
-
|
|
241
|
+
//
|
|
242
|
+
// theater_risks is built from scopedGaps (not the full relevantGaps) so a
|
|
243
|
+
// single-framework request cannot leak or mis-summarize theater controls
|
|
244
|
+
// from frameworks the operator never asked about.
|
|
245
|
+
const theaterRisks = scopedGaps
|
|
224
246
|
.filter(([, g]) => g.status === 'open' && (g.theater_test || g.theater_pattern))
|
|
225
247
|
.map(([key, g]) => ({
|
|
226
248
|
control: key,
|
|
@@ -234,19 +256,8 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, o
|
|
|
234
256
|
// explicit framework filter the summary must agree with the per-framework
|
|
235
257
|
// body the operator actually sees — otherwise `framework-gap nist-800-53
|
|
236
258
|
// <cve>` shows e.g. "2 matching control gap(s)" per-framework but "Summary:
|
|
237
|
-
// 8 matching gaps" (every framework's hits, pre-filter).
|
|
238
|
-
|
|
239
|
-
// case a single gap matches multiple requested frameworks.
|
|
240
|
-
let matchingGapCount;
|
|
241
|
-
if (opts.allFrameworks) {
|
|
242
|
-
matchingGapCount = relevantGaps.length;
|
|
243
|
-
} else {
|
|
244
|
-
const seen = new Set();
|
|
245
|
-
for (const id of frameworkIds) {
|
|
246
|
-
for (const g of frameworkResults[id]?.gaps ?? []) seen.add(g.id);
|
|
247
|
-
}
|
|
248
|
-
matchingGapCount = seen.size;
|
|
249
|
-
}
|
|
259
|
+
// 8 matching gaps" (every framework's hits, pre-filter).
|
|
260
|
+
const matchingGapCount = scopedGaps.length;
|
|
250
261
|
|
|
251
262
|
return {
|
|
252
263
|
threat_scenario: threatScenario,
|
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
|
@@ -4204,8 +4204,44 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
4204
4204
|
// would exclude it: 'critical' < 'high' lexicographically).
|
|
4205
4205
|
const SEV = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
4206
4206
|
const lr = SEV[String(lv).toLowerCase()], rr = SEV[String(rv).toLowerCase()];
|
|
4207
|
-
|
|
4208
|
-
|
|
4207
|
+
let a = (lr !== undefined && rr !== undefined) ? lr : lv;
|
|
4208
|
+
let b = (lr !== undefined && rr !== undefined) ? rr : rv;
|
|
4209
|
+
const isOrdering = op === '>=' || op === '<=' || op === '>' || op === '<';
|
|
4210
|
+
if (isOrdering && (lr === undefined || rr === undefined)) {
|
|
4211
|
+
// Duration literals carry a unit suffix (`24h`, `7d`, `30min`) — the
|
|
4212
|
+
// catalog writes ordering comparisons against them (kernel.json's
|
|
4213
|
+
// `reboot_window > 24h` raise_severity escalation). The RHS-coercion
|
|
4214
|
+
// above only converts a BARE numeric (`/^-?\d+(\.\d+)?$/`), so a unit-
|
|
4215
|
+
// suffixed literal stays a string. A numeric LHS then compares against a
|
|
4216
|
+
// string RHS (`48 > '24h'` → `48 > NaN` → false: a 48h window silently
|
|
4217
|
+
// fails to escalate) and a string LHS compares lexicographically
|
|
4218
|
+
// (`'6h' > '24h'` → `'6' > '2'` → true: a 6h window WRONGLY escalates).
|
|
4219
|
+
// Normalize both sides to canonical hours when a duration unit appears on
|
|
4220
|
+
// either side: a unit-suffixed literal converts by its unit family; a
|
|
4221
|
+
// bare number is taken in the same family as the duration it is compared
|
|
4222
|
+
// against (hours-equivalent magnitude). The comparison is then numeric.
|
|
4223
|
+
const la = parseDurationHours(a), ba = parseDurationHours(b);
|
|
4224
|
+
if ((la !== null || ba !== null) && la !== null && ba !== null) {
|
|
4225
|
+
a = la; b = ba;
|
|
4226
|
+
} else if (
|
|
4227
|
+
// Two non-numeric, non-severity, non-duration strings under an ordering
|
|
4228
|
+
// operator is a silently-degraded comparison (lexicographic / NaN) — the
|
|
4229
|
+
// clause PARSED so condition_unparsed never fires. Surface a distinct
|
|
4230
|
+
// condition_type_mismatch so the degraded comparison is observable
|
|
4231
|
+
// (the boolean result is unchanged; this is diagnostics only).
|
|
4232
|
+
typeof a !== 'number' && typeof b !== 'number' &&
|
|
4233
|
+
!(typeof a === 'string' && /^-?\d+(?:\.\d+)?$/.test(a.trim())) &&
|
|
4234
|
+
!(typeof b === 'string' && /^-?\d+(?:\.\d+)?$/.test(b.trim()))
|
|
4235
|
+
) {
|
|
4236
|
+
const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
|
|
4237
|
+
: (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
|
|
4238
|
+
: null;
|
|
4239
|
+
if (target) {
|
|
4240
|
+
pushRunError(target, { kind: 'condition_type_mismatch', condition: String(expr).slice(0, 200) },
|
|
4241
|
+
{ dedupeKey: x => x.condition || '' });
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4209
4245
|
switch (op) {
|
|
4210
4246
|
case '==': case '=': return lv == rv;
|
|
4211
4247
|
case '!=': return lv != rv;
|
|
@@ -4346,6 +4382,33 @@ function resolvePath(obj, dot) {
|
|
|
4346
4382
|
return dot.split('.').reduce((acc, k) => acc == null ? null : acc[k], obj);
|
|
4347
4383
|
}
|
|
4348
4384
|
|
|
4385
|
+
/**
|
|
4386
|
+
* Normalize a duration operand to canonical hours for a numeric comparison.
|
|
4387
|
+
* Accepts a unit-suffixed literal (`24h`, `7d`, `2wk`, `30min`) and converts by
|
|
4388
|
+
* its unit family, OR a bare number / numeric string (returned as its own
|
|
4389
|
+
* magnitude — the catalog writes `reboot_window > 24h` where the LHS resolves to
|
|
4390
|
+
* a bare hour count). Returns null for anything that is not a recognized
|
|
4391
|
+
* duration or plain number, so the caller can detect that BOTH sides normalized
|
|
4392
|
+
* before comparing numerically (and surface a type-mismatch otherwise).
|
|
4393
|
+
*/
|
|
4394
|
+
const DURATION_UNIT_HOURS = {
|
|
4395
|
+
h: 1, hr: 1, hrs: 1,
|
|
4396
|
+
m: 1 / 60, min: 1 / 60,
|
|
4397
|
+
d: 24, day: 24, days: 24,
|
|
4398
|
+
w: 168, wk: 168,
|
|
4399
|
+
};
|
|
4400
|
+
function parseDurationHours(v) {
|
|
4401
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
4402
|
+
if (typeof v !== 'string') return null;
|
|
4403
|
+
const s = v.trim();
|
|
4404
|
+
// Bare numeric string (no unit) — take its magnitude as-is.
|
|
4405
|
+
if (/^-?\d+(?:\.\d+)?$/.test(s)) return parseFloat(s);
|
|
4406
|
+
const m = s.match(/^(\d+(?:\.\d+)?)\s*(h|hr|hrs|d|day|days|wk|w|m|min)$/i);
|
|
4407
|
+
if (!m) return null;
|
|
4408
|
+
const mult = DURATION_UNIT_HOURS[m[2].toLowerCase()];
|
|
4409
|
+
return mult === undefined ? null : parseFloat(m[1]) * mult;
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4349
4412
|
/**
|
|
4350
4413
|
* Depth-aware splitter — split `expr` at occurrences of ` <sep> ` (with
|
|
4351
4414
|
* surrounding spaces) that are at parenthesis depth 0. Returns the (trimmed)
|
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`;
|
package/lib/rfc-cli.js
CHANGED
|
@@ -86,8 +86,13 @@ function titleMatches(claimed, indexTitle) {
|
|
|
86
86
|
// No contiguous run, but all tokens present out of order. Accept only when the
|
|
87
87
|
// claim covers a strong majority of the index title's tokens (containment
|
|
88
88
|
// ratio floor) — a few scattered tokens against a long title is ambiguous,
|
|
89
|
-
// not a match.
|
|
90
|
-
|
|
89
|
+
// not a match. Count DISTINCT claim tokens that appear in the title: counting
|
|
90
|
+
// non-distinct tokens lets a repeated-token claim (e.g. "security security
|
|
91
|
+
// security security") inflate the ratio past the floor and falsely match an
|
|
92
|
+
// unrelated title.
|
|
93
|
+
const distinct = new Set(claimTokens);
|
|
94
|
+
const present = [...distinct].filter((t) => titleSet.has(t)).length;
|
|
95
|
+
const ratio = present / titleTokens.length;
|
|
91
96
|
return ratio >= 0.8;
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -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/scoring.js
CHANGED
|
@@ -367,13 +367,29 @@ function scoreCustom(factors, opts) {
|
|
|
367
367
|
*/
|
|
368
368
|
function deriveRwepFromFactors(factors) {
|
|
369
369
|
if (!factors || typeof factors !== 'object') return 0;
|
|
370
|
-
const
|
|
371
|
-
if (
|
|
370
|
+
const entries = Object.entries(factors);
|
|
371
|
+
if (entries.length === 0) return 0;
|
|
372
|
+
// A boolean factor OR a string active_exploitation ladder value is Shape-A
|
|
373
|
+
// evidence — scoreCustom reads exactly those. active_exploitation's string
|
|
374
|
+
// form legitimately appears in BOTH shapes (Shape A stores it as the literal
|
|
375
|
+
// ladder string; a Shape B post-weight block can ALSO carry it as a
|
|
376
|
+
// human-readable status alongside its post-weight integers), so it is the
|
|
377
|
+
// hasPostWeightInt guard below — NOT excluding active_exploitation from this
|
|
378
|
+
// check — that disambiguates them. Excluding it here under-scored an
|
|
379
|
+
// active-exploitation-ONLY raw bag (e.g. `{ active_exploitation: 'confirmed',
|
|
380
|
+
// blast_radius: 10 }`): hasBooleanOrLadder went false, the block fell through
|
|
381
|
+
// to the Shape-B sum, and the ladder string was skipped (10 vs scoreCustom 30).
|
|
372
382
|
const aeAllowed = new Set(['none', 'unknown', 'suspected', 'theoretical', 'confirmed']);
|
|
373
|
-
const hasBooleanOrLadder =
|
|
374
|
-
(v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase())),
|
|
383
|
+
const hasBooleanOrLadder = entries.some(
|
|
384
|
+
([, v]) => (typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase()))),
|
|
375
385
|
);
|
|
376
|
-
|
|
386
|
+
// A boolean-named key carrying a post-weight integer (>=5) is unambiguous
|
|
387
|
+
// Shape-B evidence. When present, the block is Shape B even if it also carries
|
|
388
|
+
// a string active_exploitation — route to the post-weight sum, not scoreCustom.
|
|
389
|
+
const hasPostWeightInt = entries.some(
|
|
390
|
+
([k, v]) => k !== 'blast_radius' && typeof v === 'number' && Number.isFinite(v) && Math.abs(v) >= 5,
|
|
391
|
+
);
|
|
392
|
+
if (hasBooleanOrLadder && !hasPostWeightInt) {
|
|
377
393
|
return scoreCustom(factors);
|
|
378
394
|
}
|
|
379
395
|
// Shape B: catalog post-weight. Sum + clamp.
|
|
@@ -505,7 +521,14 @@ function compare(cveId, catalog, opts) {
|
|
|
505
521
|
if (entry.poc_available) driving.push('public PoC (+20)');
|
|
506
522
|
if (entry.ai_discovered || entry.ai_assisted_weaponization) driving.push('AI-discovered (+15 weaponization)');
|
|
507
523
|
if (String(entry.active_exploitation || '').trim().toLowerCase() === 'confirmed') driving.push('confirmed exploitation (+20)');
|
|
508
|
-
|
|
524
|
+
// Mirror scoreCustom's rebootFactor EXACTLY: the +5 reboot weight is added
|
|
525
|
+
// whenever a reboot is required, regardless of live_patch_available (a live
|
|
526
|
+
// patch is a temporary workaround; the full-remediation window still extends
|
|
527
|
+
// — see the RWEP_WEIGHTS header note). Gating this driver on
|
|
528
|
+
// !live_patch_available made the enumerated factors sum to less than the
|
|
529
|
+
// delta on any entry that both requires a reboot AND has a live patch
|
|
530
|
+
// available, hiding a driver the score actually counted.
|
|
531
|
+
if (entry.reboot_required || entry.patch_required_reboot) driving.push('reboot required (+5)');
|
|
509
532
|
explanation += driving.join(', ');
|
|
510
533
|
explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
|
|
511
534
|
} else if (delta < -10) {
|
|
@@ -569,6 +592,16 @@ function detectFactorShape(factors) {
|
|
|
569
592
|
let sawWeightedInt = false;
|
|
570
593
|
for (const [k, v] of Object.entries(factors)) {
|
|
571
594
|
if (k === 'blast_radius') continue; // always integer in both shapes
|
|
595
|
+
if (k === 'active_exploitation' && typeof v === 'string') {
|
|
596
|
+
// active_exploitation's string-ladder form is valid in BOTH shapes — a
|
|
597
|
+
// Shape B (post-weight) block can carry it as the human-readable status
|
|
598
|
+
// string alongside its post-weight integers, exactly the way Shape A does.
|
|
599
|
+
// So a string active_exploitation is NOT Shape-A evidence; counting it as
|
|
600
|
+
// sawBool produced a spurious 'mixed' verdict (and a validate() error) on
|
|
601
|
+
// an otherwise-clean Shape B block. Its weight, when summed, is resolved
|
|
602
|
+
// via resolveActiveExploitation in the post-weight path, not here.
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
572
605
|
if (typeof v === 'boolean' || v === null) {
|
|
573
606
|
sawBool = true;
|
|
574
607
|
} else if (typeof v === 'number' && Math.abs(v) >= 5 && boolFields.includes(k)) {
|
|
@@ -579,7 +612,7 @@ function detectFactorShape(factors) {
|
|
|
579
612
|
// 0/1 on a boolean-named field could be either shape; ambiguous, ignore.
|
|
580
613
|
continue;
|
|
581
614
|
} else if (typeof v === 'string' && boolFields.includes(k)) {
|
|
582
|
-
// String values
|
|
615
|
+
// String values on OTHER boolean-named fields are Shape A.
|
|
583
616
|
sawBool = true;
|
|
584
617
|
}
|
|
585
618
|
}
|
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
|