@blamejs/exceptd-skills 0.18.9 → 0.18.11
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 +24 -0
- package/bin/exceptd.js +197 -118
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/frequency.json +2 -2
- package/data/d3fend-catalog.json +6 -6
- package/data/playbooks/identity-sso-compromise.json +2 -2
- package/data/playbooks/sbom.json +1 -1
- package/lib/citation-resolve.js +11 -0
- package/lib/collectors/containers.js +13 -0
- package/lib/cross-ref-api.js +29 -7
- package/lib/cve-regression-watcher.js +47 -15
- package/lib/framework-gap.js +27 -5
- package/lib/gap-detectors.js +8 -3
- package/lib/lint-skills.js +3 -2
- package/lib/playbook-runner.js +60 -5
- package/lib/refresh-external.js +58 -7
- package/lib/refresh-network.js +18 -5
- package/lib/rfc-cli.js +108 -18
- package/lib/schemas/playbook.schema.json +1 -1
- package/lib/scoring.js +31 -1
- package/lib/source-advisories.js +58 -9
- package/lib/ttp-mapper.js +31 -3
- package/lib/upstream-check-cli.js +13 -1
- package/lib/validate-catalog-meta.js +51 -7
- package/lib/validate-cve-catalog.js +10 -0
- package/lib/validate-playbooks.js +19 -1
- package/lib/xml-tokenizer.js +187 -25
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +45 -9
- package/orchestrator/index.js +9 -7
- package/orchestrator/pipeline.js +62 -14
- package/orchestrator/scanner.js +40 -9
- package/package.json +1 -1
- package/sbom.cdx.json +103 -88
- package/scripts/build-indexes.js +21 -3
- package/scripts/builders/section-offsets.js +17 -8
- package/scripts/check-catalog-gap-budget.js +3 -3
- package/scripts/check-codebase-patterns.js +124 -11
- package/scripts/check-sbom-currency.js +69 -3
- package/scripts/check-test-count.js +28 -16
- package/scripts/check-test-subjects.js +127 -0
- package/scripts/check-version-tags.js +24 -5
- package/scripts/predeploy.js +13 -0
- package/scripts/refresh-upstream-catalogs.js +150 -42
- package/scripts/release.js +28 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.11 — 2026-06-22
|
|
4
|
+
|
|
5
|
+
Regenerates the CycloneDX SBOM (`sbom.cdx.json`) so its recorded hash for `CHANGELOG.md` matches the shipped file.
|
|
6
|
+
|
|
7
|
+
## 0.18.10 — 2026-06-20
|
|
8
|
+
|
|
9
|
+
A large correctness pass across the condition engine, scoring, correlation, the collectors, the validators, the orchestrator, the refresh pipeline, and the CLI.
|
|
10
|
+
|
|
11
|
+
Escalation and `feeds_into` conditions that reference host-asserted finding descriptors — `finding.includes_*`, `finding.cve_class`, `finding.tool_surface`, and the rest of that family — now evaluate. The engine merges an agent-supplied `finding` object underneath the engine-computed fields, so a host-AI assertion (e.g. "this finding includes a cloud-role-assumption path") survives into the condition while engine-owned fields like severity and RWEP still win on collision; previously the engine's finding shape replaced the agent's object wholesale and roughly three dozen catalog chains across eighteen playbooks could never fire. The SBOM playbook's EU CRA Art.14 actively-exploited escalation now fires when a matched CVE is actively exploited under an EU obligation (it compared a composite notification key against the obligation set and never matched). `analyze.classification` conditions resolve, so the identity-SSO playbook's incident chain that gates on detection classification fires. A comparison whose dotted left-hand side resolves to nothing surfaces a diagnostic instead of evaluating to an invisible false. A `present` compliance verdict scores as a detected gap in the `theater_score` exposed to chaining, matching the rest of the engine. The playbook RWEP scaler routes the active-exploitation factor through the shared resolver, so a stray-cased or out-of-vocabulary value warns rather than silently scoring zero.
|
|
12
|
+
|
|
13
|
+
`compare()` no longer fabricates a "Low / next maintenance" CVSS SLA for a CVE that carries no CVSS. The post-weight RWEP factor sum excludes unrecognised or mistyped keys (with a warning) instead of letting them inflate the score.
|
|
14
|
+
|
|
15
|
+
ATT&CK technique cross-references resolve (the technique catalog is loaded), and the D3FEND countermeasure correlation reads its populated field instead of an always-empty one. Per-framework gap counts are reported for every framework rather than collapsing to zero for most of them. The containers collector flags a multi-stage Dockerfile whose final stage runs as root. Auto-imported draft CVEs are excluded from the CWE/technique/skill correlations, matching the by-CVE lookup. D3FEND artifact ids are recognised by the reference scanner, and two artifact ids that carried a spurious trailing period are normalised — the D3FEND refresh now normalises the upstream id to the catalog key as well, so a later refresh matches the existing row instead of re-adding a period-suffixed duplicate.
|
|
16
|
+
|
|
17
|
+
`scan`, `dispatch`, and `report` honour `--air-gap` by suppressing the TLS-reachability probe — previously only `EXCEPTD_AIR_GAP=1` did. `refresh --network` refuses under air-gap even when a registry fixture is set, and the tarball host allowlist validates the host it actually connects to. The CVE regression watcher receives advisory observations so it runs, and content-only regression candidates are de-duplicated and source-merged across feeds.
|
|
18
|
+
|
|
19
|
+
The catalog, CVE, and playbook validators report a clear error on a missing `_meta` block, a null catalog entry, or a null playbook file instead of crashing mid-scan, and the catalog freshness gate rejects a malformed `last_updated` date rather than skipping it. The skill staleness gate rejects a structurally-ISO but non-calendar date (e.g. `2026-13-99`), and the playbook air-gap source detector flags API-verb-phrased network sources (`GET /…`, Microsoft Graph, Okta, Entra ID) in both the imperative validator and the schema.
|
|
20
|
+
|
|
21
|
+
The feed tokenizer preserves a title or body field that contains an unescaped `<`, surfaces parse errors on the live RSS/Atom path instead of discarding them, and captures the correct alternate-link href on multi-link Atom entries. The framework-coverage lookup no longer matches every control when the framework id is empty or very short.
|
|
22
|
+
|
|
23
|
+
`doctor --json` and `doctor --pretty` exit 0 on a warnings-only state, matching the human output (a CI consumer no longer sees a false failure). `ci --evidence-dir` applies the same symlink / no-follow / realpath defenses the multi-run evidence reader uses, and its containment check resolves the directory's real path before comparing, so a `<playbook>.json` under a symlinked parent (a symlinked mount, home, or temp directory) is read rather than refused as resolving outside the directory. An error envelope is still emitted under `--json-stdout-only`, and it is JSON even for an error raised before flag parsing (an unknown or removed verb) so a piped `| jq` consumer never receives human-formatted text on stdout; help and welcome output flushes before exit. The dispatch plan keeps an entry for every distinct finding that routes to a skill, so two findings that route to the same skill no longer collapse into one; the currency check reports a malformed `last_threat_review` as stale rather than fully current; and a null or non-object stage output yields a clear handoff error.
|
|
24
|
+
|
|
25
|
+
The upstream catalog refresh does not stamp a catalog fresh or rewrite it when the fetch returned an error body or produced no change, writes the catalog atomically, and bounds redirect depth. Section and token-budget byte offsets are computed end-of-line-aware so they stay correct on a CRLF body, and index writes retry through a transient file lock on Windows. The RFC CLI parses a claimed title regardless of position and matches titles strictly, the citation resolver binds a cached record to the requested citation, and the upstream-check CLI surfaces an unexpected error as a structured envelope.
|
|
26
|
+
|
|
3
27
|
## 0.18.9 — 2026-06-20
|
|
4
28
|
|
|
5
29
|
Internal: the refresh tarball pull and the vendor-source online check build their request URL entirely from constant and individually-validated bindings — a GitHub-name-shaped owner/repo, a hex commit id, a traversal-free path, and a strict-semver version — and the request is built from those validated bindings directly, so no unvalidated registry-metadata or provenance value reaches the outbound request. Behavior is unchanged; this tightens the data-flow into the fetchers.
|
package/bin/exceptd.js
CHANGED
|
@@ -538,6 +538,15 @@ function main() {
|
|
|
538
538
|
if (argv.includes("--json-stdout-only")) {
|
|
539
539
|
process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
|
|
540
540
|
process.env.EXCEPTD_UNSIGNED_WARNED = "1";
|
|
541
|
+
// Route the canonical error envelope (emitError) to STDOUT under this flag.
|
|
542
|
+
// Without it, --json-stdout-only sends all diagnostics to stderr and then
|
|
543
|
+
// suppresses non-"Error"-prefixed stderr — but emitError's ok:false body is
|
|
544
|
+
// neither stdout-bound nor "Error"-prefixed, so an error invocation produced
|
|
545
|
+
// NO diagnostic on stdout OR stderr (just an exit code). A `| jq` consumer
|
|
546
|
+
// reading stdout would see an empty document on every failure. Set a
|
|
547
|
+
// dedicated global so emitError can detect the flag and write its JSON
|
|
548
|
+
// envelope to stdout (where the consumer is already reading) instead.
|
|
549
|
+
global.__exceptdJsonStdoutOnly = true;
|
|
541
550
|
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
542
551
|
process.stderr.write = (chunk, encoding, cb) => {
|
|
543
552
|
// Let actual error frames through (uncaught exceptions need to surface
|
|
@@ -575,7 +584,7 @@ function main() {
|
|
|
575
584
|
|
|
576
585
|
if (argv.length === 0) {
|
|
577
586
|
printWelcome();
|
|
578
|
-
|
|
587
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
579
588
|
}
|
|
580
589
|
const cmd = argv[0];
|
|
581
590
|
const rest = argv.slice(1);
|
|
@@ -601,14 +610,14 @@ function main() {
|
|
|
601
610
|
return;
|
|
602
611
|
}
|
|
603
612
|
if (printPlaybookVerbHelp(verb)) {
|
|
604
|
-
|
|
613
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
605
614
|
}
|
|
606
615
|
// Verb not found — emit a one-line note pointing at the top-level
|
|
607
616
|
// help so operators don't silently see the wrong content.
|
|
608
617
|
process.stderr.write(`[exceptd help] no verb-specific help for "${verb}" — falling through to top-level help. Run \`exceptd help\` for the full verb list.\n`);
|
|
609
618
|
}
|
|
610
619
|
printHelp();
|
|
611
|
-
|
|
620
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
612
621
|
}
|
|
613
622
|
if (cmd === "version" || cmd === "--version" || cmd === "-v") {
|
|
614
623
|
process.stdout.write(readPkgVersion() + "\n");
|
|
@@ -919,7 +928,12 @@ function emitError(msg, extra, pretty) {
|
|
|
919
928
|
// Explicit --json / --pretty / --json-stdout-only also force JSON
|
|
920
929
|
// regardless of TTY (e.g. an operator redirecting to a file).
|
|
921
930
|
const body = Object.assign({ ok: false, error: msg }, extra || {});
|
|
922
|
-
|
|
931
|
+
// --json-stdout-only routes the envelope to stdout (below), so the envelope
|
|
932
|
+
// MUST be JSON. For a top-level error raised before flag parsing sets
|
|
933
|
+
// __exceptdWantJson (an unknown/removed verb), the stdout-only flag is the
|
|
934
|
+
// only signal present — fold it into the JSON selection so a human string is
|
|
935
|
+
// never written to the machine-readable stdout channel (breaking `| jq`).
|
|
936
|
+
const wantJson = !!global.__exceptdWantJson || !!process.env.EXCEPTD_RAW_JSON || !!global.__exceptdJsonStdoutOnly;
|
|
923
937
|
const stderrIsTty = process.stderr.isTTY === true;
|
|
924
938
|
let s;
|
|
925
939
|
if (wantJson || !stderrIsTty) {
|
|
@@ -934,7 +948,16 @@ function emitError(msg, extra, pretty) {
|
|
|
934
948
|
}
|
|
935
949
|
s = lines.join("\n");
|
|
936
950
|
}
|
|
937
|
-
|
|
951
|
+
// Under --json-stdout-only, route the JSON error envelope to STDOUT (where the
|
|
952
|
+
// consumer is already reading machine-readable output) instead of the
|
|
953
|
+
// suppressed stderr channel — otherwise the error would surface on neither
|
|
954
|
+
// stream. The flag forces JSON (global.__exceptdWantJson is set via
|
|
955
|
+
// args._jsonMode), so `s` here is already the JSON envelope, not human text.
|
|
956
|
+
if (global.__exceptdJsonStdoutOnly) {
|
|
957
|
+
process.stdout.write(s + "\n");
|
|
958
|
+
} else {
|
|
959
|
+
process.stderr.write(s + "\n");
|
|
960
|
+
}
|
|
938
961
|
process.exitCode = EXIT_CODES.GENERIC_FAILURE;
|
|
939
962
|
}
|
|
940
963
|
|
|
@@ -1252,7 +1275,7 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1252
1275
|
// get usage text instead of an error about missing arguments.
|
|
1253
1276
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
1254
1277
|
printPlaybookVerbHelp(cmd);
|
|
1255
|
-
|
|
1278
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
1256
1279
|
}
|
|
1257
1280
|
|
|
1258
1281
|
const args = parseArgs(argv, {
|
|
@@ -4191,6 +4214,144 @@ function buildJurisdictionClockRollup(results) {
|
|
|
4191
4214
|
return [...m.values()];
|
|
4192
4215
|
}
|
|
4193
4216
|
|
|
4217
|
+
// Shared, hardened reader for `--evidence-dir <dir>`. Both `run` (cmdRunMulti)
|
|
4218
|
+
// and `ci` (cmdCi) accept --evidence-dir; the symlink / junction / O_NOFOLLOW /
|
|
4219
|
+
// realpath-containment / playbook-id defenses must apply identically to both.
|
|
4220
|
+
// Previously cmdCi read entries with a bare fs.readFileSync, so a `<pb>.json`
|
|
4221
|
+
// symlink/junction inside the dir bypassed every containment check that `run`
|
|
4222
|
+
// applies. Factor the read into one helper so the class is fixed once and a
|
|
4223
|
+
// future third caller can't regress.
|
|
4224
|
+
//
|
|
4225
|
+
// Returns { ok: true, bundle } on success, or
|
|
4226
|
+
// { ok: false, error: <msg>, extra: <obj|null> } on the first refusal — the
|
|
4227
|
+
// caller routes the error through its own emitError() so the verb-prefixed
|
|
4228
|
+
// message ("run: ..." vs "ci: ...") is preserved. The directory's existence /
|
|
4229
|
+
// type and the empty-string guard are the caller's responsibility (the two
|
|
4230
|
+
// verbs surface those with verb-specific wording).
|
|
4231
|
+
function readEvidenceDir(dir, verb) {
|
|
4232
|
+
const bundle = {};
|
|
4233
|
+
const resolvedDir = path.resolve(dir);
|
|
4234
|
+
// Resolve the directory's realpath ONCE so the per-entry containment gate
|
|
4235
|
+
// below compares like-for-like. On macOS the tmpdir — and many operator
|
|
4236
|
+
// directories anywhere — live under a symlinked ancestor (e.g. /var ->
|
|
4237
|
+
// /private/var, or a symlinked mount/home). Without resolving the base, a
|
|
4238
|
+
// legitimate <pb-id>.json whose realpath is /private/var/.../f fails a
|
|
4239
|
+
// `startsWith(resolvedDir)` test against /var/.../ and every evidence file is
|
|
4240
|
+
// wrongly refused. Resolving the base keeps the junction/symlink-escape
|
|
4241
|
+
// defense (an entry whose target leaves the resolved dir still fails) while
|
|
4242
|
+
// accepting files that merely sit under a symlinked parent.
|
|
4243
|
+
let realResolvedDir;
|
|
4244
|
+
try { realResolvedDir = fs.realpathSync(resolvedDir); }
|
|
4245
|
+
catch { realResolvedDir = resolvedDir; }
|
|
4246
|
+
// Only `<playbook-id>.json` entries are honored. Reject anything where the
|
|
4247
|
+
// filename strip leaves traversal segments — npm refuses to write such
|
|
4248
|
+
// filenames so the realistic risk is an operator symlink/junction inside the
|
|
4249
|
+
// dir, but the filter is cheap.
|
|
4250
|
+
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
|
|
4251
|
+
const pbId = f.replace(/\.json$/, "");
|
|
4252
|
+
// Reuse the shared playbook-id validator so the --evidence-dir entry
|
|
4253
|
+
// filter agrees with the runtime playbook-id allowlist. Rejects
|
|
4254
|
+
// dots / underscores / uppercase that no real playbook id uses, which would
|
|
4255
|
+
// otherwise silently absorb a typo'd filename as a "valid" entry that
|
|
4256
|
+
// loadPlaybook then refused mid-loop.
|
|
4257
|
+
const pbCheck = validateIdComponent(pbId, "playbook");
|
|
4258
|
+
if (!pbCheck.ok) {
|
|
4259
|
+
return {
|
|
4260
|
+
ok: false,
|
|
4261
|
+
error: `${verb}: --evidence-dir entry ${JSON.stringify(f)} has invalid playbook-id segment (${pbCheck.reason}).`,
|
|
4262
|
+
extra: { entry: f, expected_shape: "<playbook-id>.json (lowercase, starts with letter, no dots)" },
|
|
4263
|
+
};
|
|
4264
|
+
}
|
|
4265
|
+
const entryPath = path.resolve(path.join(resolvedDir, f));
|
|
4266
|
+
if (!entryPath.startsWith(resolvedDir + path.sep)) {
|
|
4267
|
+
return { ok: false, error: `${verb}: --evidence-dir entry ${f} resolves outside the directory; refusing.`, extra: null };
|
|
4268
|
+
}
|
|
4269
|
+
// The path.resolve check above only catches `..` traversal in the joined
|
|
4270
|
+
// path; reading the path would still follow symlinks, so a
|
|
4271
|
+
// `<pb-id>.json -> /etc/shadow` symlink inside the dir would slurp the
|
|
4272
|
+
// target. Rather than lstat/realpath the PATH and then re-open it (a
|
|
4273
|
+
// check-then-use TOCTOU window), open a single O_NOFOLLOW descriptor FIRST
|
|
4274
|
+
// and make every subsequent decision about that exact descriptor.
|
|
4275
|
+
// O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; on Windows
|
|
4276
|
+
// it is a no-op, so the fstat type check + lstat + realpath gate below carry
|
|
4277
|
+
// the junction/symlink defense. Opening before any path stat means the bytes
|
|
4278
|
+
// read come from the inode we validated, not a path that could be re-pointed
|
|
4279
|
+
// between check and read.
|
|
4280
|
+
let efd;
|
|
4281
|
+
try {
|
|
4282
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
4283
|
+
efd = fs.openSync(entryPath, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
4284
|
+
} catch (e) {
|
|
4285
|
+
const why = e.code === "ELOOP"
|
|
4286
|
+
? "symbolic link refused (symlinks bypass the directory-confinement check)"
|
|
4287
|
+
: e.message;
|
|
4288
|
+
return { ok: false, error: `${verb}: --evidence-dir entry ${f}: open failed: ${why}`, extra: { entry: f } };
|
|
4289
|
+
}
|
|
4290
|
+
try {
|
|
4291
|
+
const st = fs.fstatSync(efd);
|
|
4292
|
+
if (!st.isFile()) {
|
|
4293
|
+
return { ok: false, error: `${verb}: --evidence-dir entry ${f} is not a regular file; refusing (symlink / junction / dir / fifo bypass the directory-confinement check).`, extra: { entry: f } };
|
|
4294
|
+
}
|
|
4295
|
+
// Hardlink defense in depth: no clean cross-platform refusal exists —
|
|
4296
|
+
// hardlinks are indistinguishable from regular files at the inode level.
|
|
4297
|
+
// Surface a stderr warning when nlink > 1 so the operator is aware a
|
|
4298
|
+
// second name may point at the same file. Not a refusal — legitimate use
|
|
4299
|
+
// cases (atomic rename, package-manager dedup) produce nlink > 1 without
|
|
4300
|
+
// malicious intent.
|
|
4301
|
+
if (st.nlink > 1) {
|
|
4302
|
+
process.stderr.write(`[exceptd ${verb} --evidence-dir] WARNING: ${f} has nlink=${st.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
|
|
4303
|
+
}
|
|
4304
|
+
// Read the bytes from `efd` FIRST — the descriptor was opened O_NOFOLLOW
|
|
4305
|
+
// and fstat-confirmed a regular file, so this reads the exact inode we
|
|
4306
|
+
// validated. Reading before the realpath gate (rather than checking the
|
|
4307
|
+
// path then reading) means there is no check-then-use window at all; the
|
|
4308
|
+
// containment gate below decides whether to USE the bytes, and discards
|
|
4309
|
+
// them otherwise.
|
|
4310
|
+
const raw = fs.readFileSync(efd, "utf8");
|
|
4311
|
+
// Symlink refusal. O_NOFOLLOW already rejects a symlinked leaf at open on
|
|
4312
|
+
// POSIX (ELOOP), but it is a no-op on Windows, where the open follows the
|
|
4313
|
+
// link. Detect and refuse a symlink explicitly via lstat — regardless of
|
|
4314
|
+
// where it points — so a symlinked entry is never accepted. This runs
|
|
4315
|
+
// AFTER the descriptor read (the bytes are dropped on refusal), so there
|
|
4316
|
+
// is no path-check-before-read TOCTOU window.
|
|
4317
|
+
let lst;
|
|
4318
|
+
try { lst = fs.lstatSync(entryPath); }
|
|
4319
|
+
catch (e) {
|
|
4320
|
+
return { ok: false, error: `${verb}: --evidence-dir entry ${f}: lstat failed: ${e.message}`, extra: null };
|
|
4321
|
+
}
|
|
4322
|
+
if (lst.isSymbolicLink()) {
|
|
4323
|
+
return { ok: false, error: `${verb}: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, extra: { entry: f } };
|
|
4324
|
+
}
|
|
4325
|
+
// Windows directory junctions are reparse-point dirs that
|
|
4326
|
+
// lstat().isSymbolicLink() returns FALSE for, and O_NOFOLLOW is a no-op
|
|
4327
|
+
// there; realpath resolves the entry and confirms it still lives under the
|
|
4328
|
+
// resolved evidence-dir. A target that escapes the dir is refused and the
|
|
4329
|
+
// already-read bytes are dropped unused.
|
|
4330
|
+
let realEntry;
|
|
4331
|
+
try { realEntry = fs.realpathSync(entryPath); }
|
|
4332
|
+
catch (e) {
|
|
4333
|
+
return { ok: false, error: `${verb}: --evidence-dir entry ${f}: realpath failed: ${e.message}`, extra: null };
|
|
4334
|
+
}
|
|
4335
|
+
if (!realEntry.startsWith(realResolvedDir + path.sep)) {
|
|
4336
|
+
return {
|
|
4337
|
+
ok: false,
|
|
4338
|
+
error: `${verb}: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
|
|
4339
|
+
extra: { entry: f, resolved_to: realEntry },
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4342
|
+
bundle[pbId] = JSON.parse(raw);
|
|
4343
|
+
} catch (e) {
|
|
4344
|
+
// A refusal object thrown by JSON.parse / readFileSync lands here; surface
|
|
4345
|
+
// it with the entry name. (The explicit refusals above return directly and
|
|
4346
|
+
// never reach this catch.)
|
|
4347
|
+
return { ok: false, error: `${verb}: failed to read --evidence-dir entry ${f}: ${e.message}`, extra: null };
|
|
4348
|
+
} finally {
|
|
4349
|
+
try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
return { ok: true, bundle };
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4194
4355
|
function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
4195
4356
|
const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
|
|
4196
4357
|
runOpts.session_id = sessionId;
|
|
@@ -4228,110 +4389,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
4228
4389
|
if (!fs.existsSync(dir)) {
|
|
4229
4390
|
return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
|
|
4230
4391
|
}
|
|
4231
|
-
|
|
4232
|
-
//
|
|
4233
|
-
//
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
const pbId = f.replace(/\.json$/, "");
|
|
4238
|
-
// Reuse the shared playbook-id validator so the --evidence-dir entry
|
|
4239
|
-
// filter agrees with the runtime playbook-id allowlist. Previously
|
|
4240
|
-
// accepted dots / underscores / uppercase that no real playbook id
|
|
4241
|
-
// uses, which would silently absorb a typo'd filename as a "valid"
|
|
4242
|
-
// entry that loadPlaybook then refused mid-loop.
|
|
4243
|
-
const pbCheck = validateIdComponent(pbId, "playbook");
|
|
4244
|
-
if (!pbCheck.ok) {
|
|
4245
|
-
return emitError(
|
|
4246
|
-
`run: --evidence-dir entry ${JSON.stringify(f)} has invalid playbook-id segment (${pbCheck.reason}).`,
|
|
4247
|
-
{ entry: f, expected_shape: "<playbook-id>.json (lowercase, starts with letter, no dots)" },
|
|
4248
|
-
pretty
|
|
4249
|
-
);
|
|
4250
|
-
}
|
|
4251
|
-
const entryPath = path.resolve(path.join(resolvedDir, f));
|
|
4252
|
-
if (!entryPath.startsWith(resolvedDir + path.sep)) {
|
|
4253
|
-
return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
|
|
4254
|
-
}
|
|
4255
|
-
// The path.resolve check above only catches `..` traversal in the
|
|
4256
|
-
// joined path; reading the path would still follow symlinks, so a
|
|
4257
|
-
// `<pb-id>.json -> /etc/shadow` symlink inside the dir would slurp the
|
|
4258
|
-
// target. Rather than lstat/realpath the PATH and then re-open it
|
|
4259
|
-
// (a check-then-use TOCTOU window), open a single O_NOFOLLOW descriptor
|
|
4260
|
-
// FIRST and make every subsequent decision about that exact descriptor.
|
|
4261
|
-
// O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; on
|
|
4262
|
-
// Windows it is a no-op, so the fstat type check + realpath gate below
|
|
4263
|
-
// carry the junction/symlink defense. Opening before any path stat means
|
|
4264
|
-
// the bytes read come from the inode we validated, not a path that could
|
|
4265
|
-
// be re-pointed between check and read.
|
|
4266
|
-
let efd;
|
|
4267
|
-
try {
|
|
4268
|
-
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
4269
|
-
efd = fs.openSync(entryPath, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
4270
|
-
} catch (e) {
|
|
4271
|
-
const why = e.code === "ELOOP"
|
|
4272
|
-
? "symbolic link refused (symlinks bypass the directory-confinement check)"
|
|
4273
|
-
: e.message;
|
|
4274
|
-
return emitError(`run: --evidence-dir entry ${f}: open failed: ${why}`, { entry: f }, pretty);
|
|
4275
|
-
}
|
|
4276
|
-
try {
|
|
4277
|
-
const st = fs.fstatSync(efd);
|
|
4278
|
-
if (!st.isFile()) {
|
|
4279
|
-
return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing (symlink / junction / dir / fifo bypass the directory-confinement check).`, { entry: f }, pretty);
|
|
4280
|
-
}
|
|
4281
|
-
// Hardlink defense in depth: no clean cross-platform refusal exists —
|
|
4282
|
-
// hardlinks are indistinguishable from regular files at the inode
|
|
4283
|
-
// level. Surface a stderr warning when nlink > 1 so the operator is
|
|
4284
|
-
// aware a second name may point at the same file. Not a refusal —
|
|
4285
|
-
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
4286
|
-
// nlink > 1 without malicious intent.
|
|
4287
|
-
if (st.nlink > 1) {
|
|
4288
|
-
process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${st.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
|
|
4289
|
-
}
|
|
4290
|
-
// Read the bytes from `efd` FIRST — the descriptor was opened
|
|
4291
|
-
// O_NOFOLLOW and fstat-confirmed a regular file, so this reads the exact
|
|
4292
|
-
// inode we validated. Reading before the realpath gate (rather than
|
|
4293
|
-
// checking the path then reading) means there is no check-then-use
|
|
4294
|
-
// window at all; the containment gate below decides whether to USE the
|
|
4295
|
-
// bytes, and discards them otherwise.
|
|
4296
|
-
const raw = fs.readFileSync(efd, "utf8");
|
|
4297
|
-
// Symlink refusal. O_NOFOLLOW already rejects a symlinked leaf at open on
|
|
4298
|
-
// POSIX (ELOOP), but it is a no-op on Windows, where the open follows the
|
|
4299
|
-
// link. Detect and refuse a symlink explicitly via lstat — regardless of
|
|
4300
|
-
// where it points — so a symlinked entry is never accepted. This runs
|
|
4301
|
-
// AFTER the descriptor read (the bytes are dropped on refusal), so there
|
|
4302
|
-
// is no path-check-before-read TOCTOU window.
|
|
4303
|
-
let lst;
|
|
4304
|
-
try { lst = fs.lstatSync(entryPath); }
|
|
4305
|
-
catch (e) {
|
|
4306
|
-
return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
|
|
4307
|
-
}
|
|
4308
|
-
if (lst.isSymbolicLink()) {
|
|
4309
|
-
return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
|
|
4310
|
-
}
|
|
4311
|
-
// Windows directory junctions are reparse-point dirs that
|
|
4312
|
-
// lstat().isSymbolicLink() returns FALSE for, and O_NOFOLLOW is a
|
|
4313
|
-
// no-op there; realpath resolves the entry and confirms it still lives
|
|
4314
|
-
// under the resolved evidence-dir. A target that escapes the dir is
|
|
4315
|
-
// refused and the already-read bytes are dropped unused.
|
|
4316
|
-
let realEntry;
|
|
4317
|
-
try { realEntry = fs.realpathSync(entryPath); }
|
|
4318
|
-
catch (e) {
|
|
4319
|
-
return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
|
|
4320
|
-
}
|
|
4321
|
-
if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
|
|
4322
|
-
return emitError(
|
|
4323
|
-
`run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
|
|
4324
|
-
{ entry: f, resolved_to: realEntry },
|
|
4325
|
-
pretty
|
|
4326
|
-
);
|
|
4327
|
-
}
|
|
4328
|
-
bundle[pbId] = JSON.parse(raw);
|
|
4329
|
-
} catch (e) {
|
|
4330
|
-
return emitError(`run: failed to read --evidence-dir entry ${f}: ${e.message}`, null, pretty);
|
|
4331
|
-
} finally {
|
|
4332
|
-
try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
|
|
4333
|
-
}
|
|
4334
|
-
}
|
|
4392
|
+
// Hardened read (symlink / junction / O_NOFOLLOW / realpath-containment /
|
|
4393
|
+
// playbook-id gate) lives in the shared readEvidenceDir() helper so `run`
|
|
4394
|
+
// and `ci` apply identical defenses.
|
|
4395
|
+
const er = readEvidenceDir(dir, "run");
|
|
4396
|
+
if (!er.ok) return emitError(er.error, er.extra, pretty);
|
|
4397
|
+
Object.assign(bundle, er.bundle);
|
|
4335
4398
|
}
|
|
4336
4399
|
|
|
4337
4400
|
const results = [];
|
|
@@ -7815,7 +7878,15 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
7815
7878
|
|
|
7816
7879
|
if (wantJson) {
|
|
7817
7880
|
emit(out, indent);
|
|
7818
|
-
|
|
7881
|
+
// Exit-code predicate must match the human path (gates on errorList only):
|
|
7882
|
+
// warnings alone do NOT force exit 1. The body still carries all_green,
|
|
7883
|
+
// warnings_count, and warning_checks for consumers that want the full
|
|
7884
|
+
// picture; only the exit code stops conflating a warn-only nudge (missing
|
|
7885
|
+
// private key on a consumer install, registry-behind) with a hard error.
|
|
7886
|
+
// A genuine signature-verification failure sets ok:false WITHOUT
|
|
7887
|
+
// severity:"warn", so bucketChecks routes it to errorList and it still
|
|
7888
|
+
// forces exit 1 here.
|
|
7889
|
+
if (errorList.length > 0) process.exitCode = EXIT_CODES.GENERIC_FAILURE;
|
|
7819
7890
|
return;
|
|
7820
7891
|
}
|
|
7821
7892
|
|
|
@@ -9044,18 +9115,25 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
9044
9115
|
try { bundle = readEvidence(args.evidence); }
|
|
9045
9116
|
catch (e) { return emitError(`ci: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
9046
9117
|
}
|
|
9118
|
+
if (args["evidence-dir"] === "") {
|
|
9119
|
+
return emitError("ci: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir", { verb: "ci", flag: "evidence-dir" }, pretty);
|
|
9120
|
+
}
|
|
9047
9121
|
if (args["evidence-dir"]) {
|
|
9048
9122
|
const dir = args["evidence-dir"];
|
|
9123
|
+
if (typeof dir !== "string") {
|
|
9124
|
+
return emitError("ci: --evidence-dir must be a string.", null, pretty);
|
|
9125
|
+
}
|
|
9049
9126
|
if (!fs.existsSync(dir)) {
|
|
9050
9127
|
return emitError(`ci: --evidence-dir ${dir} does not exist.`, null, pretty);
|
|
9051
9128
|
}
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9129
|
+
// Hardened read (symlink / junction / O_NOFOLLOW / realpath-containment /
|
|
9130
|
+
// playbook-id gate) lives in the shared readEvidenceDir() helper so `ci`
|
|
9131
|
+
// and `run` apply identical defenses. Previously `ci` read entries with a
|
|
9132
|
+
// bare fs.readFileSync, so a `<pb>.json` symlink/junction inside the dir
|
|
9133
|
+
// bypassed every containment check `run` applies.
|
|
9134
|
+
const er = readEvidenceDir(dir, "ci");
|
|
9135
|
+
if (!er.ok) return emitError(er.error, er.extra, pretty);
|
|
9136
|
+
Object.assign(bundle, er.bundle);
|
|
9059
9137
|
}
|
|
9060
9138
|
|
|
9061
9139
|
// Flat-submission tolerance for a single positional playbook. `ci` keys its
|
|
@@ -9579,4 +9657,5 @@ module.exports = {
|
|
|
9579
9657
|
_diffArtifacts: diffArtifacts,
|
|
9580
9658
|
_diffSignalOverrides: diffSignalOverrides,
|
|
9581
9659
|
_resolveSelfAttestation: resolveSelfAttestation,
|
|
9660
|
+
_readEvidenceDir: readEvidenceDir,
|
|
9582
9661
|
};
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-06-
|
|
3
|
+
"generated_at": "2026-06-22T04:26:25.259Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 64,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "c1c37465664024760c34e6c236592ee071e5347d3fb736dba3a495fee6768126",
|
|
8
8
|
"README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
|
|
9
9
|
"data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
|
|
10
10
|
"data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
|
|
11
11
|
"data/cve-catalog.json": "06ca53e69071dfe94867c10717f3ff2962c50341defcaf050f1a96236b5be51c",
|
|
12
12
|
"data/cwe-catalog.json": "359263361fa52069e2856cc352d8f1c757d614ea840db6ef3ff5e696185ca220",
|
|
13
|
-
"data/d3fend-catalog.json": "
|
|
13
|
+
"data/d3fend-catalog.json": "b5c1e83d216ba8ece6be1368d807d1636f3d77622fd4e9148e9ba2f5fd44ee0d",
|
|
14
14
|
"data/dlp-controls.json": "d2406c482dddd30e49203879999dc4b3a7fd4d0494d6a61d86b91ee76415df19",
|
|
15
15
|
"data/exploit-availability.json": "ec2656f0d9a893610e27b43eb6035fe9b18e057c9f6dfaac7e7d4959bbcbb795",
|
|
16
16
|
"data/framework-control-gaps.json": "760c2275803c6da3665ce538c5176bde6f041b68cc3d4808b8de961dfcdee6b8",
|
package/data/d3fend-catalog.json
CHANGED
|
@@ -6062,8 +6062,8 @@
|
|
|
6062
6062
|
"_auto_imported": true,
|
|
6063
6063
|
"_intake_method": "mitre-d3fend-owl"
|
|
6064
6064
|
},
|
|
6065
|
-
"D3A-C4
|
|
6066
|
-
"id": "D3A-C4
|
|
6065
|
+
"D3A-C4": {
|
|
6066
|
+
"id": "D3A-C4",
|
|
6067
6067
|
"name": "C4.5",
|
|
6068
6068
|
"tactic": "Defensive",
|
|
6069
6069
|
"description": "C4.5 is an algorithm that is strongly based off ID3.",
|
|
@@ -6077,13 +6077,13 @@
|
|
|
6077
6077
|
"requires": [],
|
|
6078
6078
|
"inventories": [],
|
|
6079
6079
|
"kb_reference": null,
|
|
6080
|
-
"reference_url": "https://d3fend.mitre.org/technique/D3A-C4
|
|
6080
|
+
"reference_url": "https://d3fend.mitre.org/technique/D3A-C4/",
|
|
6081
6081
|
"last_verified": "2026-05-19",
|
|
6082
6082
|
"_auto_imported": true,
|
|
6083
6083
|
"_intake_method": "mitre-d3fend-owl"
|
|
6084
6084
|
},
|
|
6085
|
-
"D3A-C5
|
|
6086
|
-
"id": "D3A-C5
|
|
6085
|
+
"D3A-C5": {
|
|
6086
|
+
"id": "D3A-C5",
|
|
6087
6087
|
"name": "C5.0",
|
|
6088
6088
|
"tactic": "Defensive",
|
|
6089
6089
|
"description": "C5.0 is the next version of C4.5, which in turn is the upgrade from ID3.",
|
|
@@ -6097,7 +6097,7 @@
|
|
|
6097
6097
|
"requires": [],
|
|
6098
6098
|
"inventories": [],
|
|
6099
6099
|
"kb_reference": null,
|
|
6100
|
-
"reference_url": "https://d3fend.mitre.org/technique/D3A-C5
|
|
6100
|
+
"reference_url": "https://d3fend.mitre.org/technique/D3A-C5/",
|
|
6101
6101
|
"last_verified": "2026-05-19",
|
|
6102
6102
|
"_auto_imported": true,
|
|
6103
6103
|
"_intake_method": "mitre-d3fend-owl"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"feeds_into": [
|
|
47
47
|
{
|
|
48
48
|
"playbook_id": "idp-incident",
|
|
49
|
-
"condition": "
|
|
49
|
+
"condition": "analyze.classification == 'detected'"
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
"playbook_id": "cred-stores",
|
|
@@ -642,7 +642,7 @@
|
|
|
642
642
|
],
|
|
643
643
|
"escalation_criteria": [
|
|
644
644
|
{
|
|
645
|
-
"condition": "
|
|
645
|
+
"condition": "analyze.classification == 'detected'",
|
|
646
646
|
"action": "trigger_playbook",
|
|
647
647
|
"target_playbook": "idp-incident"
|
|
648
648
|
},
|
package/data/playbooks/sbom.json
CHANGED
|
@@ -1247,7 +1247,7 @@
|
|
|
1247
1247
|
"action": "notify_legal"
|
|
1248
1248
|
},
|
|
1249
1249
|
{
|
|
1250
|
-
"condition": "any
|
|
1250
|
+
"condition": "any matched_cve.active_exploitation == 'confirmed' AND jurisdiction_obligations contains 'EU'",
|
|
1251
1251
|
"action": "notify_legal"
|
|
1252
1252
|
}
|
|
1253
1253
|
]
|
package/lib/citation-resolve.js
CHANGED
|
@@ -87,6 +87,17 @@ function cacheGet(kind, id) {
|
|
|
87
87
|
if (!Number.isFinite(ts)) return null;
|
|
88
88
|
const age = Date.now() - ts;
|
|
89
89
|
if (age < -60_000 || age > CACHE_TTL_MS) return null;
|
|
90
|
+
// Bind the record to the requested key — a digest proves self-consistency,
|
|
91
|
+
// not that this is the record FOR the looked-up id/kind. A digest-valid
|
|
92
|
+
// record written under one filename but carrying a different internal
|
|
93
|
+
// id/kind would otherwise be served for the wrong lookup (a swapped-file
|
|
94
|
+
// poisoning that the self-digest cannot catch). Mismatch → cache miss.
|
|
95
|
+
if (record.kind !== kind) return null;
|
|
96
|
+
if (kind === "cve") {
|
|
97
|
+
if (typeof record.id !== "string" || record.id.toUpperCase() !== String(id).toUpperCase()) return null;
|
|
98
|
+
} else if (kind === "rfc") {
|
|
99
|
+
if (Number(record.number) !== Number(id)) return null;
|
|
100
|
+
}
|
|
90
101
|
delete record._digest; // internal integrity field — never surface it
|
|
91
102
|
return record;
|
|
92
103
|
} catch { return null; }
|
|
@@ -191,6 +191,19 @@ function scanDockerfile(content, rel) {
|
|
|
191
191
|
hits["dockerfile-from-latest"].push({ file: rel, line: i + 1, snippet: trimmed.slice(0, 120) });
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
|
+
// USER does NOT carry across Docker build stages: each stage starts
|
|
195
|
+
// from its base image's default user (root) until its own USER
|
|
196
|
+
// directive. A `FROM <prior-stage-alias>` is the one exception — it
|
|
197
|
+
// inherits the parent stage's USER, so it must NOT reset. Any other
|
|
198
|
+
// FROM (registry image, or `scratch`) begins a fresh stage and resets
|
|
199
|
+
// the per-stage USER tracking, so the runs-as-root predicate reflects
|
|
200
|
+
// only the FINAL stage. Evaluate internal-vs-external against the
|
|
201
|
+
// ARG-resolved `ref` and BEFORE the current alias is recorded.
|
|
202
|
+
const isInternalStageRef = stageAliases.has(ref.toLowerCase());
|
|
203
|
+
if (!isInternalStageRef) {
|
|
204
|
+
sawNonRootUser = false;
|
|
205
|
+
sawAnyUser = false;
|
|
206
|
+
}
|
|
194
207
|
// Record this stage's alias so later `FROM <alias>` lines are recognized
|
|
195
208
|
// as internal references.
|
|
196
209
|
if (alias) stageAliases.add(alias.toLowerCase());
|