@blamejs/exceptd-skills 0.14.9 → 0.14.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 +32 -0
- package/bin/exceptd.js +166 -49
- package/data/_indexes/_meta.json +2 -2
- package/lib/citation-resolve.js +4 -1
- package/lib/collectors/cicd-pipeline-compromise.js +8 -2
- package/lib/collectors/citation-hygiene.js +10 -5
- package/lib/collectors/crypto-codebase.js +11 -6
- package/lib/collectors/sbom.js +9 -2
- package/lib/collectors/scan-excludes.js +0 -0
- package/lib/collectors/secrets.js +32 -4
- package/lib/cve-cli.js +5 -1
- package/lib/framework-gap.js +21 -2
- package/lib/playbook-runner.js +23 -10
- package/lib/prefetch.js +5 -1
- package/lib/refresh-external.js +29 -4
- package/lib/refresh-network.js +16 -1
- package/manifest.json +44 -44
- package/orchestrator/index.js +61 -6
- package/package.json +1 -1
- package/sbom.cdx.json +42 -42
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.11 — 2026-05-27
|
|
4
|
+
|
|
5
|
+
Security: `reattest <session-id>` now validates the session-id before it is joined into a filesystem path, the same gate the other read verbs use. A `../`-bearing id previously escaped the attestation root — reading a forged attestation and writing a signed replay record outside the root. Such an id is now refused (exit 1) and nothing is written.
|
|
6
|
+
|
|
7
|
+
Air-gap is now honored on every external-source path that previously leaked. `watchlist --org-scan`, `refresh --network`, and `prefetch` all consulted the network even under `--air-gap` / `EXCEPTD_AIR_GAP=1`; each now refuses (or, for `prefetch`, runs report-only) instead of egressing.
|
|
8
|
+
|
|
9
|
+
The `sbom` collector no longer reports `lockfile-no-integrity` on every clean repository. It counted the npm lockfile's root entry — which legitimately has no integrity hash — as a missing-integrity dependency, so the indicator fired on any normal `package-lock.json`. It now counts only remote-tarball entries that lack integrity.
|
|
10
|
+
|
|
11
|
+
The `secrets` collector no longer fires on the published AWS documentation example key (`AKIAIOSFODNN7EXAMPLE`), and a text file skipped for exceeding the size limit is now surfaced in `collector_errors` instead of being dropped silently. Secret/citation/crypto findings now carry the exact line in their evidence locations, so SARIF points at the line rather than the file.
|
|
12
|
+
|
|
13
|
+
Cache-integrity refusals during `refresh` (sha256 mismatch, tampered or unindexed cache) now exit 4 — the documented "cache precondition failed" code — instead of the generic 1. `refresh --source ""` errors with the valid-source list instead of silently running every source; `cve " "` (whitespace) is treated as a missing argument; `refresh --advisory " "` gets the dedicated empty-advisory message. `refresh --help` documents exit 1 and the full meaning of exit 4.
|
|
14
|
+
|
|
15
|
+
Human-readable output gaps closed across several verbs:
|
|
16
|
+
- `run --all` / `run-all` print a per-playbook summary table instead of dumping the full JSON.
|
|
17
|
+
- `attest diff --against` renders the same one-screen summary the no-argument form already did, rather than raw JSON.
|
|
18
|
+
- A matched CVE renders `KEV=Y`/`KEV=N` (not the raw boolean); a deterministic indicator no longer prints `deterministic/deterministic`; a truncated remediation, an over-long fired-indicator list, and the `ci` framework-gap / jurisdiction-clock rollups now show how much was elided; a preflight warning that carries its text in `message` and a runtime warning that carries only context fields are now shown instead of `(no detail)` / a blank line.
|
|
19
|
+
- `framework-gap <framework> <scenario>` summary line counts only the queried framework's gaps, matching the per-framework body (it previously reported the all-frameworks total).
|
|
20
|
+
- `report executive` writes its progress notice to stderr so piped markdown is clean.
|
|
21
|
+
- The synopsis now describes `watchlist` (the one-shot forward-watch aggregator) and `watch` (the long-running daemon) correctly; the inverted deprecation arrow is gone. `cve`/`rfc` help states their exit-2 contract.
|
|
22
|
+
|
|
23
|
+
## 0.14.10 — 2026-05-27
|
|
24
|
+
|
|
25
|
+
`ci <playbook> --evidence -` no longer reports a false PASS when handed a flat submission. `run` accepts a flat submission (`{ "signal_overrides": {...} }`) and so do operators by habit; `ci` keyed the input by playbook id, found nothing under that key, and evaluated an empty submission — a detected finding came back PASS. A single-positional `ci` invocation now treats a flat (non-bundle-shaped) submission as belonging to that playbook, so `ci` and `run` agree. A real bundle keyed by playbook id is still routed per-key.
|
|
26
|
+
|
|
27
|
+
`ai-run <playbook> --no-stream --evidence -` now rejects a non-object submission (`null`, an array, a scalar) at the read boundary, matching `run`. Previously the no-stream path skipped the shape guard and ran a malformed submission as if it were empty, so an operator believed a bad payload had been evaluated.
|
|
28
|
+
|
|
29
|
+
The `ci` framework-gap rollup now carries the gap explanation. Each rollup entry's `why_insufficient` was always null because the rollup read a field that doesn't exist on a gap record; the text lives in `actual_gap`, which is now surfaced (alongside `required_control`).
|
|
30
|
+
|
|
31
|
+
A regulatory clock now starts on an engine-confirmed detection, not only on an agent-submitted classification. When indicators fire and the engine itself classifies the detect phase as `detected`, `--ack` starts the notification clock and computes the jurisdiction deadlines — previously the clock only moved if the submission also carried `detection_classification: "detected"`, so an engine-confirmed finding left every deadline stalled at `pending_clock_start_event`.
|
|
32
|
+
|
|
33
|
+
`framework-gap <framework> <scenario>` refuses an unknown framework instead of returning a zero-gap report that reads as "no gaps found." A typo or an untracked framework now errors with the list of frameworks the catalog covers; the documented short forms (`NIST-800-53`, `PCI-DSS-4.0`) and `all` continue to resolve.
|
|
34
|
+
|
|
3
35
|
## 0.14.9 — 2026-05-27
|
|
4
36
|
|
|
5
37
|
`refresh --advisory <id> --air-gap` now refuses (no network) instead of egressing. The `--air-gap` flag was parsed but dropped before the fetch, so an air-gapped advisory seed silently reached GHSA/OSV — an air-gap-guarantee violation. Both the flag and the `EXCEPTD_AIR_GAP=1` env now refuse identically.
|
package/bin/exceptd.js
CHANGED
|
@@ -464,7 +464,8 @@ Canonical verbs
|
|
|
464
464
|
(citation-hygiene) resolves uncatalogued citations.
|
|
465
465
|
skill <name> Show context for a specific skill.
|
|
466
466
|
framework-gap <fw> <ref> Programmatic gap analysis (one framework, one CVE/scenario).
|
|
467
|
-
|
|
467
|
+
watchlist [--alerts] Forward-watch aggregator across skills (one-shot).
|
|
468
|
+
watch Long-running forward-watch daemon (blocks; Ctrl-C).
|
|
468
469
|
report [executive] Structured posture report.
|
|
469
470
|
path Absolute path to the installed package.
|
|
470
471
|
version Package version.
|
|
@@ -519,7 +520,6 @@ surfaces.
|
|
|
519
520
|
[DEPRECATED] verify → doctor --signatures
|
|
520
521
|
[DEPRECATED] validate-cves → doctor --cves
|
|
521
522
|
[DEPRECATED] validate-rfcs → doctor --rfcs
|
|
522
|
-
[DEPRECATED] watchlist → watch
|
|
523
523
|
[DEPRECATED] prefetch → refresh --no-network
|
|
524
524
|
[DEPRECATED] build-indexes → refresh --indexes-only
|
|
525
525
|
|
|
@@ -759,8 +759,8 @@ function main() {
|
|
|
759
759
|
skill: "exceptd skill <name> Show the full context document for one skill.",
|
|
760
760
|
"framework-gap": "exceptd framework-gap <framework> <cve-or-scenario> One-framework gap analysis.",
|
|
761
761
|
"framework-gap-analysis": "exceptd framework-gap <framework> <cve-or-scenario> One-framework gap analysis.",
|
|
762
|
-
cve: "exceptd cve <CVE-ID> [--json] [--air-gap|--no-network] Resolve a CVE: published/rejected/disputed/fabricated/nonexistent (catalog -> cache -> NVD).",
|
|
763
|
-
rfc: "exceptd rfc <number> [--check \"<title>\"] [--json] [--air-gap] Resolve an RFC number -> title + status (local index, offline).",
|
|
762
|
+
cve: "exceptd cve <CVE-ID> [--json] [--air-gap|--no-network] Resolve a CVE: published/rejected/disputed/fabricated/nonexistent (catalog -> cache -> NVD). Exit 2 when the citation won't stand up (rejected/fabricated/nonexistent/withdrawn).",
|
|
763
|
+
rfc: "exceptd rfc <number> [--check \"<title>\"] [--json] [--air-gap] Resolve an RFC number -> title + status (local index, offline). Exit 2 when nonexistent or --check title MISMATCH.",
|
|
764
764
|
};
|
|
765
765
|
if ((effectiveRest.includes("--help") || effectiveRest.includes("-h")) && SPAWN_HELP_USAGE[effectiveCmd]) {
|
|
766
766
|
process.stdout.write(SPAWN_HELP_USAGE[effectiveCmd] + "\n Full reference: exceptd help\n");
|
|
@@ -2858,13 +2858,14 @@ function cmdBrief(runner, args, runOpts, pretty) {
|
|
|
2858
2858
|
const required = (obj.artifacts || []).filter(a => a.required);
|
|
2859
2859
|
const optional = (obj.artifacts || []).filter(a => !a.required);
|
|
2860
2860
|
lines.push(`\nRequired artifacts (${required.length}): ${required.map(a => a.id).join(", ") || "(none)"}`);
|
|
2861
|
-
if (optional.length) lines.push(`Optional artifacts (${optional.length}): ${optional.map(a => a.id).slice(0, 8).join(", ")}${optional.length > 8 ?
|
|
2861
|
+
if (optional.length) lines.push(`Optional artifacts (${optional.length}): ${optional.map(a => a.id).slice(0, 8).join(", ")}${optional.length > 8 ? `, … +${optional.length - 8}` : ""}`);
|
|
2862
2862
|
const indicators = obj.detect_indicators_preview || [];
|
|
2863
|
-
lines.push(`\nIndicators (${indicators.length}): ${indicators.map(i => i.id).slice(0, 8).join(", ")}${indicators.length > 8 ?
|
|
2863
|
+
lines.push(`\nIndicators (${indicators.length}): ${indicators.map(i => i.id).slice(0, 8).join(", ")}${indicators.length > 8 ? `, … +${indicators.length - 8}` : ""}`);
|
|
2864
2864
|
if (obj.preconditions?.length) {
|
|
2865
2865
|
lines.push(`\nPreconditions (${obj.preconditions.length}):`);
|
|
2866
2866
|
for (const p of obj.preconditions) {
|
|
2867
|
-
|
|
2867
|
+
const pdesc = p.description || p.check || "";
|
|
2868
|
+
lines.push(` ${p.id} (${p.on_fail}): ${pdesc.length > 80 ? pdesc.slice(0, 80) + "…" : pdesc}`);
|
|
2868
2869
|
}
|
|
2869
2870
|
}
|
|
2870
2871
|
lines.push(`\nRun: exceptd run ${obj.playbook_id} --evidence <file|-> --json`);
|
|
@@ -3859,7 +3860,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3859
3860
|
lines.push(`\nMatched CVEs (${cves.length}):`);
|
|
3860
3861
|
for (const c of cves.slice(0, 6)) {
|
|
3861
3862
|
const via = Array.isArray(c.correlated_via) && c.correlated_via.length ? ` via ${c.correlated_via[0]}${c.correlated_via.length > 1 ? ` (+${c.correlated_via.length - 1})` : ""}` : "";
|
|
3862
|
-
lines.push(` ${c.cve_id} RWEP ${c.rwep} KEV=${c.cisa_kev} ${c.active_exploitation || ""}${via}`);
|
|
3863
|
+
lines.push(` ${c.cve_id} RWEP ${c.rwep} KEV=${c.cisa_kev ? "Y" : "N"} ${c.active_exploitation || ""}${via}`);
|
|
3863
3864
|
}
|
|
3864
3865
|
if (cves.length > 6) lines.push(` … ${cves.length - 6} more`);
|
|
3865
3866
|
} else if (baseline.length) {
|
|
@@ -3872,7 +3873,13 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3872
3873
|
const hits = indicators.filter(i => i.verdict === "hit");
|
|
3873
3874
|
if (hits.length) {
|
|
3874
3875
|
lines.push(`\nIndicators that fired (${hits.length}):`);
|
|
3875
|
-
for (const i of hits.slice(0, 8))
|
|
3876
|
+
for (const i of hits.slice(0, 8)) {
|
|
3877
|
+
// Don't double-print "deterministic/deterministic" when confidence is
|
|
3878
|
+
// already the literal "deterministic".
|
|
3879
|
+
const detSuffix = (i.deterministic && i.confidence !== "deterministic") ? "/deterministic" : "";
|
|
3880
|
+
lines.push(` ${i.id} (${i.confidence}${detSuffix})`);
|
|
3881
|
+
}
|
|
3882
|
+
if (hits.length > 8) lines.push(` … ${hits.length - 8} more`);
|
|
3876
3883
|
}
|
|
3877
3884
|
// selected_remediation is informational on non-detect runs:
|
|
3878
3885
|
// validate() always picks the highest-priority remediation path
|
|
@@ -3888,7 +3895,8 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3888
3895
|
} else {
|
|
3889
3896
|
lines.push(`\nRemediation path (informational — verdict=${cls}, no action required now): ${rem.id} (priority ${rem.priority})`);
|
|
3890
3897
|
}
|
|
3891
|
-
|
|
3898
|
+
const remDesc = rem.description || "";
|
|
3899
|
+
lines.push(` ${remDesc.length > 200 ? remDesc.slice(0, 200) + "… (full steps: --json)" : remDesc}`);
|
|
3892
3900
|
}
|
|
3893
3901
|
// Surface BOTH started and pending notification clocks on detected
|
|
3894
3902
|
// runs. The detection IS the regulatory event for the obligations
|
|
@@ -3940,7 +3948,9 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3940
3948
|
// to the description if `check` is missing too.
|
|
3941
3949
|
for (const i of issues) {
|
|
3942
3950
|
const tag = i.on_fail ? `[${i.on_fail}] ` : "";
|
|
3943
|
-
|
|
3951
|
+
// precondition_warn issues carry their text in `message`; without it in
|
|
3952
|
+
// the fallback chain they rendered "(no detail)".
|
|
3953
|
+
const detail = i.check || i.description || i.reason || i.message || "(no detail)";
|
|
3944
3954
|
lines.push(` ${tag}${i.id}: ${detail}`);
|
|
3945
3955
|
}
|
|
3946
3956
|
}
|
|
@@ -3953,7 +3963,11 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3953
3963
|
if (runtimeErrors.length) {
|
|
3954
3964
|
lines.push(`\nRuntime warnings (${runtimeErrors.length}):`);
|
|
3955
3965
|
for (const e of runtimeErrors) {
|
|
3956
|
-
|
|
3966
|
+
// Some runtime-warning kinds (e.g. csaf_branch_unparseable) carry no
|
|
3967
|
+
// `reason` but do carry context fields (component / cve_id); compose
|
|
3968
|
+
// from those rather than rendering a blank line.
|
|
3969
|
+
const rawReason = e.reason || [e.component, e.cve_id].filter(Boolean).join(" / ") || "(no detail)";
|
|
3970
|
+
const reason = rawReason.length > 180 ? rawReason.slice(0, 177) + "..." : rawReason;
|
|
3957
3971
|
lines.push(` [${e.kind || "warning"}] ${reason}`);
|
|
3958
3972
|
if (e.remediation) lines.push(` → ${e.remediation}`);
|
|
3959
3973
|
}
|
|
@@ -4242,7 +4256,39 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
4242
4256
|
},
|
|
4243
4257
|
jurisdiction_clock_rollup: jurisdictionClockRollup,
|
|
4244
4258
|
results,
|
|
4245
|
-
}, pretty)
|
|
4259
|
+
}, pretty, (obj) => {
|
|
4260
|
+
// Per-playbook summary table. Without this renderer a multi-run dumped its
|
|
4261
|
+
// entire (often hundreds-of-KB) JSON even in default human mode.
|
|
4262
|
+
const s = obj.summary;
|
|
4263
|
+
const lines = [];
|
|
4264
|
+
const detectedTotal = s.detected;
|
|
4265
|
+
const icon = s.blocked > 0 ? "[!! BLOCKED]" : detectedTotal > 0 ? "[!! DETECTED]" : "[ok]";
|
|
4266
|
+
lines.push(`run ${obj.trigger || "multi"}: ${obj.playbooks_run.length} playbook(s) session-id: ${obj.session_id}`);
|
|
4267
|
+
lines.push(`\n${icon} detected=${detectedTotal} inconclusive=${s.inconclusive} clean=${s.total - detectedTotal - s.inconclusive - s.blocked} blocked=${s.blocked} total=${s.total}`);
|
|
4268
|
+
const rows = (obj.results || []).map(r => (r && r.ok === false)
|
|
4269
|
+
? { id: r.playbook_id || "?", verdict: "blocked", rwep: "-", evidence: r.evidence_completeness || "not-evaluated", top: r.blocked_by || r.reason || r.error || "" }
|
|
4270
|
+
: { id: r.playbook_id || "?", verdict: r?.phases?.detect?.classification || r?.verdict || "?", rwep: (r?.rwep_score != null) ? String(r.rwep_score) : "-", evidence: r?.evidence_completeness || "unknown", top: r?.top_finding || "" });
|
|
4271
|
+
const wId = Math.max(8, ...rows.map(r => r.id.length));
|
|
4272
|
+
const wV = Math.max(8, ...rows.map(r => r.verdict.length));
|
|
4273
|
+
const wR = Math.max(4, ...rows.map(r => r.rwep.length));
|
|
4274
|
+
const wE = Math.max(8, ...rows.map(r => r.evidence.length));
|
|
4275
|
+
const pad = (str, w) => (str + " ".repeat(w)).slice(0, w);
|
|
4276
|
+
lines.push("");
|
|
4277
|
+
lines.push(` ${pad("playbook", wId)} ${pad("verdict", wV)} ${pad("rwep", wR)} ${pad("evidence", wE)} finding`);
|
|
4278
|
+
lines.push(` ${"-".repeat(wId)} ${"-".repeat(wV)} ${"-".repeat(wR)} ${"-".repeat(wE)} -------`);
|
|
4279
|
+
for (const row of rows) {
|
|
4280
|
+
const finding = row.top.length > 80 ? row.top.slice(0, 77) + "..." : row.top;
|
|
4281
|
+
lines.push(` ${pad(row.id, wId)} ${pad(row.verdict, wV)} ${pad(row.rwep, wR)} ${pad(row.evidence, wE)} ${finding}`);
|
|
4282
|
+
}
|
|
4283
|
+
const clocks = obj.jurisdiction_clock_rollup || [];
|
|
4284
|
+
if (clocks.length) {
|
|
4285
|
+
lines.push(`\nJurisdiction clocks (${clocks.length}):`);
|
|
4286
|
+
for (const n of clocks.slice(0, 5)) lines.push(` ${n.jurisdiction || "?"}/${n.regulation || "?"} → deadline ${n.deadline || "?"}`);
|
|
4287
|
+
if (clocks.length > 5) lines.push(` … ${clocks.length - 5} more (--json for all)`);
|
|
4288
|
+
}
|
|
4289
|
+
lines.push(`\nFull structured results: --json or --pretty`);
|
|
4290
|
+
return lines.join("\n");
|
|
4291
|
+
});
|
|
4246
4292
|
// v0.11.9 (#100): cmdRunMulti exits non-zero when any individual run
|
|
4247
4293
|
// returned ok:false. Pre-0.11.9 the aggregate result had {ok:false} in
|
|
4248
4294
|
// the body but exit code stayed 0 — CI gates couldn't distinguish "ran
|
|
@@ -5076,6 +5122,15 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
5076
5122
|
attFile = found.file;
|
|
5077
5123
|
}
|
|
5078
5124
|
if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
|
|
5125
|
+
// Validate the session-id BEFORE it is joined into a filesystem path. The
|
|
5126
|
+
// other read verbs (attest show/verify/diff --against) gate on this; reattest
|
|
5127
|
+
// did not, so `findSessionDir` returning null let the `||` fallback join an
|
|
5128
|
+
// unvalidated `../`-bearing id straight onto the attestation root — escaping
|
|
5129
|
+
// it to read a forged attestation and write a signed replay record outside
|
|
5130
|
+
// the root. Ids resolved from the store via the latest-match path are already
|
|
5131
|
+
// safe; an operator-supplied id is the one that must be checked.
|
|
5132
|
+
try { validateSessionIdForRead(sessionId); }
|
|
5133
|
+
catch (e) { return emitError(`reattest: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty); }
|
|
5079
5134
|
const dir = findSessionDir(sessionId, runOpts) || path.join(resolveAttestationRoot(runOpts), sessionId);
|
|
5080
5135
|
if (!attFile) attFile = path.join(dir, "attestation.json");
|
|
5081
5136
|
if (!fs.existsSync(attFile)) {
|
|
@@ -5437,6 +5492,31 @@ function classifySidecarVerify(verify) {
|
|
|
5437
5492
|
* show <session-id> Emit the full (unredacted) attestation. Convenience
|
|
5438
5493
|
* alias for `cat .exceptd/attestations/<sid>/attestation.json`.
|
|
5439
5494
|
*/
|
|
5495
|
+
// Shared one-screen renderer for `attest diff` (both the --against and the
|
|
5496
|
+
// no-against/most-recent-prior branches). Reads only fields off the emitted
|
|
5497
|
+
// object so both call sites render identically; the sidecar line is shown only
|
|
5498
|
+
// when a sidecar verification was performed (the --against path may omit it).
|
|
5499
|
+
function renderAttestDiff(obj) {
|
|
5500
|
+
const lines = [];
|
|
5501
|
+
lines.push(`attest diff: ${obj.a_session}${obj.a_playbook ? ` (${obj.a_playbook})` : ""}`);
|
|
5502
|
+
lines.push(` vs ${obj.b_session}${obj.b_captured ? ` (captured ${obj.b_captured})` : ""}`);
|
|
5503
|
+
const icon = obj.status === "unchanged" ? "[ok]" : "[!]";
|
|
5504
|
+
lines.push(` ${icon} status=${obj.status} evidence_hash=${(obj.a_evidence_hash || "").slice(0, 12)}...`);
|
|
5505
|
+
const ad = obj.artifact_diff || {};
|
|
5506
|
+
const sd = obj.signal_override_diff || {};
|
|
5507
|
+
lines.push(` artifact diff: ${ad.added?.length ?? 0} added, ${ad.removed?.length ?? 0} removed, ${ad.changed?.length ?? 0} changed, ${ad.unchanged_count ?? 0} unchanged (of ${ad.total_compared ?? 0})`);
|
|
5508
|
+
lines.push(` signal diff: ${sd.changed?.length ?? 0} changed, ${sd.unchanged_count ?? 0} unchanged (of ${sd.total_compared ?? 0})`);
|
|
5509
|
+
if (obj.sidecar_verify) {
|
|
5510
|
+
const sv = obj.sidecar_verify;
|
|
5511
|
+
let sidecarClass = "verified";
|
|
5512
|
+
if (!sv.signed && sv.reason && sv.reason.includes("explicitly unsigned")) sidecarClass = "explicitly-unsigned";
|
|
5513
|
+
else if (!sv.signed && sv.reason && sv.reason.includes("no .sig sidecar")) sidecarClass = "no-sidecar";
|
|
5514
|
+
else if (sv.signed && !sv.verified) sidecarClass = "tamper-detected";
|
|
5515
|
+
else if (!sv.signed) sidecarClass = "no-public-key";
|
|
5516
|
+
lines.push(` sidecar verify: ${sidecarClass}`);
|
|
5517
|
+
}
|
|
5518
|
+
return lines.join("\n");
|
|
5519
|
+
}
|
|
5440
5520
|
function cmdAttest(runner, args, runOpts, pretty) {
|
|
5441
5521
|
const subverb = args._[0];
|
|
5442
5522
|
const sessionId = args._[1];
|
|
@@ -5574,12 +5654,14 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5574
5654
|
emit({
|
|
5575
5655
|
verb: "attest diff",
|
|
5576
5656
|
a_session: sessionId,
|
|
5657
|
+
a_playbook: self.playbook_id,
|
|
5577
5658
|
b_session: args.against,
|
|
5578
5659
|
a_captured: self.captured_at,
|
|
5579
5660
|
b_captured: other.captured_at,
|
|
5580
5661
|
a_evidence_hash: self.evidence_hash,
|
|
5581
5662
|
b_evidence_hash: other.evidence_hash,
|
|
5582
5663
|
status: self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted",
|
|
5664
|
+
sidecar_verify: verifyAttestationSidecar(path.join(dir, "attestation.json")),
|
|
5583
5665
|
// v0.11.8 (#102): normalize submissions before diffing so flat-shape
|
|
5584
5666
|
// (observations + verdict) submissions emit meaningful artifact_diff
|
|
5585
5667
|
// counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
|
|
@@ -5593,7 +5675,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5593
5675
|
normalizedSignalOverrides(self.submission, runner, self.playbook_id),
|
|
5594
5676
|
normalizedSignalOverrides(other.submission, runner, other.playbook_id)
|
|
5595
5677
|
),
|
|
5596
|
-
}, pretty);
|
|
5678
|
+
}, pretty, renderAttestDiff);
|
|
5597
5679
|
return;
|
|
5598
5680
|
}
|
|
5599
5681
|
// No --against: find the most-recent prior attestation for the
|
|
@@ -5628,6 +5710,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5628
5710
|
emit({
|
|
5629
5711
|
verb: "attest diff",
|
|
5630
5712
|
a_session: sessionId,
|
|
5713
|
+
a_playbook: self.playbook_id,
|
|
5631
5714
|
b_session: prior.sessionId,
|
|
5632
5715
|
a_captured: self.captured_at,
|
|
5633
5716
|
b_captured: other.captured_at,
|
|
@@ -5643,28 +5726,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5643
5726
|
normalizedSignalOverrides(self.submission, runner, self.playbook_id),
|
|
5644
5727
|
normalizedSignalOverrides(other.submission, runner, other.playbook_id),
|
|
5645
5728
|
),
|
|
5646
|
-
}, pretty,
|
|
5647
|
-
// Human renderer for the no-against `attest diff` path. Same
|
|
5648
|
-
// one-screen shape the old cmdReattest renderer used so the
|
|
5649
|
-
// operator sees the verdict + drift summary + sidecar class.
|
|
5650
|
-
const lines = [];
|
|
5651
|
-
lines.push(`attest diff: ${obj.a_session} (${self.playbook_id})`);
|
|
5652
|
-
lines.push(` vs prior: ${obj.b_session} (captured ${obj.b_captured})`);
|
|
5653
|
-
const icon = obj.status === "unchanged" ? "[ok]" : "[!]";
|
|
5654
|
-
lines.push(` ${icon} status=${obj.status} evidence_hash=${(obj.a_evidence_hash || "").slice(0, 12)}...`);
|
|
5655
|
-
const ad = obj.artifact_diff || {};
|
|
5656
|
-
const sd = obj.signal_override_diff || {};
|
|
5657
|
-
lines.push(` artifact diff: ${ad.added?.length ?? 0} added, ${ad.removed?.length ?? 0} removed, ${ad.changed?.length ?? 0} changed, ${ad.unchanged_count ?? 0} unchanged (of ${ad.total_compared ?? 0})`);
|
|
5658
|
-
lines.push(` signal diff: ${sd.changed?.length ?? 0} changed, ${sd.unchanged_count ?? 0} unchanged (of ${sd.total_compared ?? 0})`);
|
|
5659
|
-
const sv = obj.sidecar_verify || {};
|
|
5660
|
-
let sidecarClass = "verified";
|
|
5661
|
-
if (!sv.signed && sv.reason && sv.reason.includes("explicitly unsigned")) sidecarClass = "explicitly-unsigned";
|
|
5662
|
-
else if (!sv.signed && sv.reason && sv.reason.includes("no .sig sidecar")) sidecarClass = "no-sidecar";
|
|
5663
|
-
else if (sv.signed && !sv.verified) sidecarClass = "tamper-detected";
|
|
5664
|
-
else if (!sv.signed) sidecarClass = "no-public-key";
|
|
5665
|
-
lines.push(` sidecar verify: ${sidecarClass}`);
|
|
5666
|
-
return lines.join("\n");
|
|
5667
|
-
});
|
|
5729
|
+
}, pretty, renderAttestDiff);
|
|
5668
5730
|
return;
|
|
5669
5731
|
}
|
|
5670
5732
|
|
|
@@ -7544,17 +7606,47 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
7544
7606
|
// Read any pre-supplied evidence from stdin OR from --evidence flag.
|
|
7545
7607
|
let payload = { observations: {}, verdict: {} };
|
|
7546
7608
|
if (args.evidence) {
|
|
7547
|
-
|
|
7609
|
+
// Apply the same shape guard `run` enforces at its read boundary: a
|
|
7610
|
+
// submission must be a JSON object. Without this, `--no-stream` accepted
|
|
7611
|
+
// `null` / `[]` / a scalar and ran as if empty, so an operator believed a
|
|
7612
|
+
// malformed submission was evaluated (the streaming path is unaffected —
|
|
7613
|
+
// it only fires on a well-formed evidence event).
|
|
7614
|
+
try { payload = asEvidenceObject(readEvidence(args.evidence)); }
|
|
7548
7615
|
catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
7549
7616
|
} else if (hasReadableStdin()) {
|
|
7550
7617
|
// hasReadableStdin() probes via fstat before falling into
|
|
7551
7618
|
// readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
|
|
7552
7619
|
// size===0) would otherwise hang here.
|
|
7553
7620
|
// Drain stdin for any evidence event.
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
|
|
7621
|
+
let buf = "";
|
|
7622
|
+
try { buf = fs.readFileSync(0, "utf8"); }
|
|
7623
|
+
catch { /* stdin empty / unreadable — fall through with empty payload */ }
|
|
7624
|
+
if (buf.trim()) {
|
|
7625
|
+
// First treat stdin as a single JSON document — the common
|
|
7626
|
+
// `echo '<json>' | ai-run … --no-stream` shape. If it parses as one
|
|
7627
|
+
// value we can apply the same shape guard `--evidence` gets: a bare
|
|
7628
|
+
// `null` / `[]` / scalar is a malformed submission, not "no evidence",
|
|
7629
|
+
// and must be rejected rather than silently run as empty.
|
|
7630
|
+
let single;
|
|
7631
|
+
let singleParsed = false;
|
|
7632
|
+
try { single = JSON.parse(buf); singleParsed = true; } catch { /* not a single doc — fall to JSONL scan */ }
|
|
7633
|
+
if (singleParsed) {
|
|
7634
|
+
// An evidence event wrapper is the one object shape that is NOT
|
|
7635
|
+
// itself the submission — unwrap it before guarding.
|
|
7636
|
+
if (single && typeof single === "object" && !Array.isArray(single) && single.event === "evidence" && single.payload) {
|
|
7637
|
+
payload = single.payload;
|
|
7638
|
+
} else {
|
|
7639
|
+
try { payload = asEvidenceObject(single); }
|
|
7640
|
+
catch (e) { return emitError(`ai-run: failed to read evidence from stdin: ${e.message}`, null, pretty); }
|
|
7641
|
+
// Normalize a bare submission into the {observations, verdict} shape.
|
|
7642
|
+
if (!payload.observations && (payload.artifacts || payload.signal_overrides || payload.signals)) {
|
|
7643
|
+
payload = { observations: { ...(payload.artifacts || {}), ...(payload.signal_overrides || {}) }, verdict: payload.signals || {} };
|
|
7644
|
+
}
|
|
7645
|
+
}
|
|
7646
|
+
} else {
|
|
7647
|
+
// JSONL / interleaved host-AI chatter: scan line-by-line for the
|
|
7648
|
+
// first evidence event or bare submission, ignoring non-matching
|
|
7649
|
+
// status frames the host may interleave.
|
|
7558
7650
|
for (const line of buf.split(/\r?\n/)) {
|
|
7559
7651
|
const t = line.trim();
|
|
7560
7652
|
if (!t) continue;
|
|
@@ -7574,7 +7666,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
7574
7666
|
} catch { /* skip non-JSON lines */ }
|
|
7575
7667
|
}
|
|
7576
7668
|
}
|
|
7577
|
-
}
|
|
7669
|
+
}
|
|
7578
7670
|
}
|
|
7579
7671
|
const submission = buildSubmissionFromPayload(payload);
|
|
7580
7672
|
let result;
|
|
@@ -8213,6 +8305,20 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8213
8305
|
}
|
|
8214
8306
|
}
|
|
8215
8307
|
|
|
8308
|
+
// Flat-submission tolerance for a single positional playbook. `ci` keys its
|
|
8309
|
+
// bundle by playbook id (so --evidence-dir / multi-playbook bundles work),
|
|
8310
|
+
// but `ci <pb> --evidence -` with the SAME flat/nested submission shape that
|
|
8311
|
+
// `run` accepts would otherwise land as bundle[<pb>]=undefined → empty run →
|
|
8312
|
+
// a false PASS that silently ignores the operator's evidence. When exactly
|
|
8313
|
+
// one playbook is in scope and the bundle carries no playbook-id key (it's a
|
|
8314
|
+
// single submission, not a multi-playbook bundle), treat it as that
|
|
8315
|
+
// playbook's evidence.
|
|
8316
|
+
if (ids.length === 1 && Object.keys(bundle).length > 0 && !(ids[0] in bundle)) {
|
|
8317
|
+
const allIds = new Set(runner.listPlaybooks());
|
|
8318
|
+
const looksLikeBundle = Object.keys(bundle).some(k => allIds.has(k));
|
|
8319
|
+
if (!looksLikeBundle) bundle = { [ids[0]]: bundle };
|
|
8320
|
+
}
|
|
8321
|
+
|
|
8216
8322
|
const results = [];
|
|
8217
8323
|
let fail = false;
|
|
8218
8324
|
let failReasons = [];
|
|
@@ -8340,7 +8446,10 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8340
8446
|
gapRollupMap.set(key, {
|
|
8341
8447
|
framework: g.framework || null,
|
|
8342
8448
|
claimed_control: g.claimed_control || null,
|
|
8343
|
-
|
|
8449
|
+
// The explanatory text lives in `actual_gap`; the rollup previously
|
|
8450
|
+
// read a nonexistent `why_insufficient` key and so was always null.
|
|
8451
|
+
why_insufficient: g.actual_gap || g.why_insufficient || null,
|
|
8452
|
+
required_control: g.required_control || null,
|
|
8344
8453
|
playbooks: [r.playbook_id],
|
|
8345
8454
|
});
|
|
8346
8455
|
}
|
|
@@ -8529,17 +8638,21 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8529
8638
|
// Jurisdiction clocks.
|
|
8530
8639
|
if (s.jurisdiction_clocks_started > 0) {
|
|
8531
8640
|
lines.push(`\nJurisdiction clocks started: ${s.jurisdiction_clocks_started}`);
|
|
8532
|
-
|
|
8641
|
+
const clocks = s.jurisdiction_clock_rollup || [];
|
|
8642
|
+
for (const n of clocks.slice(0, 5)) {
|
|
8533
8643
|
lines.push(` ${n.jurisdiction || "?"}/${n.regulation || "?"} → deadline ${n.deadline || "?"}`);
|
|
8534
8644
|
}
|
|
8645
|
+
if (clocks.length > 5) lines.push(` … ${clocks.length - 5} more (--json for all)`);
|
|
8535
8646
|
}
|
|
8536
8647
|
|
|
8537
8648
|
// Framework gap rollup.
|
|
8538
8649
|
if (s.framework_gap_count > 0) {
|
|
8539
8650
|
lines.push(`\nFramework gaps (${s.framework_gap_count}):`);
|
|
8540
|
-
|
|
8651
|
+
const fgaps = s.framework_gap_rollup || [];
|
|
8652
|
+
for (const g of fgaps.slice(0, 5)) {
|
|
8541
8653
|
lines.push(` ${g.framework || "?"} :: ${g.claimed_control || "?"} (${g.playbooks.length} playbook(s))`);
|
|
8542
8654
|
}
|
|
8655
|
+
if (fgaps.length > 5) lines.push(` … ${fgaps.length - 5} more (--json for all)`);
|
|
8543
8656
|
}
|
|
8544
8657
|
|
|
8545
8658
|
// Fail reasons.
|
|
@@ -8561,17 +8674,21 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8561
8674
|
// CLOCK_STARTED → notification clock running; see deadline above.
|
|
8562
8675
|
// PASS → nothing to do.
|
|
8563
8676
|
const blockedRows = (obj.results || []).filter(r => r && r.ok === false);
|
|
8564
|
-
|
|
8677
|
+
// Pad the playbook id to a common width so the trailing `#` comments line
|
|
8678
|
+
// up across variable-length ids instead of using a fixed space run.
|
|
8679
|
+
const lintCmd = (id, w) => ` exceptd lint ${(id + " ".repeat(w)).slice(0, w)} - # paste {} on stdin, get exact JSON paths`;
|
|
8565
8680
|
if (s.verdict === "BLOCKED" && blockedRows.length) {
|
|
8566
8681
|
lines.push(`\nNext steps (unblock the ${blockedRows.length} halted playbook(s)):`);
|
|
8567
|
-
|
|
8568
|
-
|
|
8682
|
+
const shown = blockedRows.slice(0, 4);
|
|
8683
|
+
const wLint = Math.max(...shown.map(r => (r.playbook_id || "?").length));
|
|
8684
|
+
for (const row of shown) {
|
|
8685
|
+
lines.push(lintCmd(row.playbook_id || "?", wLint));
|
|
8569
8686
|
}
|
|
8570
8687
|
lines.push(` exceptd run <playbook> --evidence <file> # re-run after filling in evidence`);
|
|
8571
8688
|
} else if (s.verdict === "NO_EVIDENCE") {
|
|
8572
8689
|
const firstId = (obj.results[0] && obj.results[0].playbook_id) || (obj.playbooks_run[0]) || "<playbook>";
|
|
8573
8690
|
lines.push(`\nNext steps (every playbook ran inconclusive — no evidence supplied):`);
|
|
8574
|
-
lines.push(lintCmd(firstId));
|
|
8691
|
+
lines.push(lintCmd(firstId, firstId.length));
|
|
8575
8692
|
lines.push(` exceptd ci --scope <type> --evidence-dir <dir> # gate again with real submissions`);
|
|
8576
8693
|
} else if (s.verdict === "FAIL") {
|
|
8577
8694
|
// FAIL fires in two distinct shapes:
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-05-
|
|
3
|
+
"generated_at": "2026-05-27T20:36:13.795Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 54,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "3db842fa75688111c96edd57712b9447a3df84cb250df1e052ac45b38aff74f2",
|
|
8
8
|
"data/atlas-ttps.json": "d24bc02859d40ccf1615db75cca68c077585904e41e0d8f6de448121e9b1abb0",
|
|
9
9
|
"data/attack-techniques.json": "fa193f0d2d248176a8beddb641e9fe56ba4faa9e15dc253ff876dbf0c5d58a77",
|
|
10
10
|
"data/cve-catalog.json": "3d451dda7ac0c7d57a4075ae4bafd3148c6184b35dc1bc59d8b81d1f2641e430",
|
package/lib/citation-resolve.js
CHANGED
|
@@ -116,7 +116,10 @@ function isAirGap(opts) {
|
|
|
116
116
|
* from: format | catalog | cache | network | offline | error
|
|
117
117
|
*/
|
|
118
118
|
async function resolveCve(id, opts = {}) {
|
|
119
|
-
|
|
119
|
+
// Trim before the format test — matches resolveRfc — so a whitespace-only
|
|
120
|
+
// identifier is "fabricated/malformed" (empty form) rather than a literal
|
|
121
|
+
// whitespace string fed straight into CVE_RE.
|
|
122
|
+
const cveId = String(id || "").trim().toUpperCase();
|
|
120
123
|
const base = { id: cveId, kind: "cve" };
|
|
121
124
|
|
|
122
125
|
if (!CVE_RE.test(cveId)) {
|
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
|
|
22
22
|
const fs = require("node:fs");
|
|
23
23
|
const path = require("node:path");
|
|
24
|
-
const { isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
24
|
+
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
25
|
+
|
|
26
|
+
// Shared code-scope name exclusions (dependency caches, build output, VCS +
|
|
27
|
+
// agent scratch). Threaded into the OIDC-policy descent so a trust JSON in a
|
|
28
|
+
// build-output dir (e.g. `dist/`) is not scanned — consistent with the other
|
|
29
|
+
// tree-walking collectors.
|
|
30
|
+
const OIDC_WALK_EXCLUDES = codeExcludeSet();
|
|
25
31
|
|
|
26
32
|
const COLLECTOR_ID = "cicd-pipeline-compromise";
|
|
27
33
|
|
|
@@ -218,7 +224,7 @@ function scanOidcPolicies(root) {
|
|
|
218
224
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
219
225
|
catch { return; }
|
|
220
226
|
for (const e of entries) {
|
|
221
|
-
if (
|
|
227
|
+
if (OIDC_WALK_EXCLUDES.has(e.name)) continue;
|
|
222
228
|
const full = path.join(dir, e.name);
|
|
223
229
|
if (e.isDirectory()) {
|
|
224
230
|
// Skip linked git worktrees (gitdir-pointer `.git` file), e.g.
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
const fs = require("node:fs");
|
|
31
31
|
const path = require("node:path");
|
|
32
32
|
|
|
33
|
-
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
33
|
+
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
|
|
34
34
|
|
|
35
35
|
const COLLECTOR_ID = "citation-hygiene";
|
|
36
36
|
|
|
@@ -334,12 +334,15 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
334
334
|
for (const m of content.matchAll(CVE_CITATION_RE)) {
|
|
335
335
|
const full = m[0];
|
|
336
336
|
totalCveCitations++;
|
|
337
|
+
// 1-based line of the citation so the evidence location carries a SARIF
|
|
338
|
+
// startLine region. Does not change any hit/miss verdict.
|
|
339
|
+
const cveLine = lineFromOffset(content, m.index);
|
|
337
340
|
const canonical = CVE_CANONICAL_RE.test(full);
|
|
338
341
|
if (!canonical) {
|
|
339
342
|
// Fabricated / malformed. Illustrative surfaces (templates,
|
|
340
343
|
// fixtures, the format-explaining docs) are demoted.
|
|
341
344
|
if (!illustrative) {
|
|
342
|
-
hits["fabricated-cve-id"].push({ file: f.rel, citation: full });
|
|
345
|
+
hits["fabricated-cve-id"].push({ file: f.rel, citation: full, line: cveLine });
|
|
343
346
|
}
|
|
344
347
|
continue;
|
|
345
348
|
}
|
|
@@ -347,7 +350,7 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
347
350
|
if (cveKeys.has(full)) {
|
|
348
351
|
const note = cveNotes.get(full) || "";
|
|
349
352
|
if (REJECT_DISPUTE_RE.test(note) && !illustrative) {
|
|
350
|
-
hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full });
|
|
353
|
+
hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full, line: cveLine });
|
|
351
354
|
}
|
|
352
355
|
} else if (catalogsLoaded && !illustrative) {
|
|
353
356
|
// Absent from the curated catalog: needs an external lookup.
|
|
@@ -362,6 +365,7 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
362
365
|
const num = Number(m[1]);
|
|
363
366
|
if (!Number.isFinite(num)) continue;
|
|
364
367
|
const line = lineAround(content, m.index);
|
|
368
|
+
const rfcLineNo = lineFromOffset(content, m.index);
|
|
365
369
|
if (rfcTitles.has(num)) {
|
|
366
370
|
const verdict = classifyRfcTitle(line, rfcTitles.get(num));
|
|
367
371
|
if (verdict === "mismatch" && !illustrative) {
|
|
@@ -369,6 +373,7 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
369
373
|
file: f.rel,
|
|
370
374
|
citation: `RFC ${num}`,
|
|
371
375
|
real_title: rfcTitles.get(num),
|
|
376
|
+
line: rfcLineNo,
|
|
372
377
|
});
|
|
373
378
|
}
|
|
374
379
|
} else if (catalogsLoaded && !illustrative) {
|
|
@@ -449,8 +454,8 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
449
454
|
|
|
450
455
|
// Per-indicator file locations for the indicators flipped to "hit",
|
|
451
456
|
// so SARIF results point at the source file that carries the bad
|
|
452
|
-
// citation. The hits record
|
|
453
|
-
//
|
|
457
|
+
// citation. The hits record a 1-based `line` (from the match offset),
|
|
458
|
+
// so locations include a startLine region.
|
|
454
459
|
const evidence_locations = {};
|
|
455
460
|
for (const id of Object.keys(hits)) {
|
|
456
461
|
if (signal_overrides[id] === "hit") {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require("node:fs");
|
|
18
18
|
const path = require("node:path");
|
|
19
|
-
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
19
|
+
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
|
|
20
20
|
|
|
21
21
|
const COLLECTOR_ID = "crypto-codebase";
|
|
22
22
|
|
|
@@ -298,14 +298,17 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
298
298
|
if (RSA_1024_RE.test(content)) {
|
|
299
299
|
hits["rsa-1024-anywhere"].push({ file: f.rel });
|
|
300
300
|
}
|
|
301
|
+
// Attach a 1-based `line` (from the match offset) so the evidence
|
|
302
|
+
// location carries a SARIF startLine region rather than pointing at
|
|
303
|
+
// the file. Does not change hit/miss — the same matches still fire.
|
|
301
304
|
const mrHits = scanMathRandom(content);
|
|
302
|
-
for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset });
|
|
305
|
+
for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset) });
|
|
303
306
|
|
|
304
307
|
const pHits = scanPbkdf2(content);
|
|
305
|
-
for (const h of pHits) hits["pbkdf2-under-iterated"].push({ file: f.rel, iter: h.iter, threshold: h.threshold });
|
|
308
|
+
for (const h of pHits) hits["pbkdf2-under-iterated"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset), iter: h.iter, threshold: h.threshold });
|
|
306
309
|
|
|
307
310
|
const bHits = scanBcrypt(content);
|
|
308
|
-
for (const h of bHits) hits["bcrypt-cost-low"].push({ file: f.rel, cost: h.cost });
|
|
311
|
+
for (const h of bHits) hits["bcrypt-cost-low"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset), cost: h.cost });
|
|
309
312
|
|
|
310
313
|
if (PEM_RE.test(content)) {
|
|
311
314
|
hits["hardcoded-key-material"].push({ file: f.rel });
|
|
@@ -460,8 +463,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
460
463
|
// no-ml-kem-implementation, fips-claim-without-runtime-activation,
|
|
461
464
|
// vendored-pqc-no-provenance) describe a whole-repo state rather than a
|
|
462
465
|
// single offending file, so they carry no file-level location. The
|
|
463
|
-
// call-site scans
|
|
464
|
-
//
|
|
466
|
+
// offset-bearing call-site scans (math-random / pbkdf2 / bcrypt) now record
|
|
467
|
+
// a 1-based `line`, so their locations include a startLine region; the
|
|
468
|
+
// remaining whole-file scans (weak-hash / weak-cipher / rsa-1024 /
|
|
469
|
+
// hardcoded-key / tls) stay file-level (no startLine).
|
|
465
470
|
const evidence_locations = {};
|
|
466
471
|
for (const id of Object.keys(hits)) {
|
|
467
472
|
if (signal_overrides[id] === "hit") {
|
package/lib/collectors/sbom.js
CHANGED
|
@@ -356,8 +356,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
356
356
|
let withoutIntegrity = 0;
|
|
357
357
|
const walk = (obj) => {
|
|
358
358
|
if (!obj || typeof obj !== "object") return;
|
|
359
|
-
|
|
360
|
-
|
|
359
|
+
// Only remote-tarball entries (those with a `resolved` URL) are
|
|
360
|
+
// expected to carry an `integrity` hash. The npm 7+ root entry
|
|
361
|
+
// `"": { name, version }` legitimately has no `resolved` and no
|
|
362
|
+
// `integrity`, so keying off `version` would false-positive on
|
|
363
|
+
// every clean lockfile. Mirror library-author.js's guard.
|
|
364
|
+
if (obj.resolved != null) {
|
|
365
|
+
if (obj.integrity != null) withIntegrity++;
|
|
366
|
+
else withoutIntegrity++;
|
|
367
|
+
}
|
|
361
368
|
for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
|
|
362
369
|
};
|
|
363
370
|
walk(j.packages || j.dependencies || {});
|
|
Binary file
|