@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9

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.
Files changed (107) hide show
  1. package/CHANGELOG.md +236 -0
  2. package/bin/agentic-security.js +186 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +183 -14
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +14 -14
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +18 -10
  19. package/src/dataflow/CLAUDE.md +10 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/engine.js +324 -60
  22. package/src/dataflow/implicit-flow.js +68 -36
  23. package/src/dataflow/incremental.js +18 -3
  24. package/src/dataflow/index.js +2 -1
  25. package/src/dataflow/proven-clean.js +41 -0
  26. package/src/dataflow/sanitizer-gate.js +35 -9
  27. package/src/dataflow/sanitizer-proof.js +21 -3
  28. package/src/dataflow/stub-aware-filter.js +36 -13
  29. package/src/dataflow/summaries.js +21 -2
  30. package/src/engine.js +202 -42
  31. package/src/ir/CLAUDE.md +4 -1
  32. package/src/ir/balanced-call.js +55 -0
  33. package/src/ir/parser-cs.js +62 -9
  34. package/src/ir/parser-go.js +29 -11
  35. package/src/ir/parser-java.js +96 -19
  36. package/src/ir/parser-js.js +151 -20
  37. package/src/ir/parser-php.js +44 -9
  38. package/src/ir/parser-rb.js +37 -7
  39. package/src/ir/ssa.js +6 -1
  40. package/src/lsp/server.js +28 -2
  41. package/src/mcp/CLAUDE.md +9 -2
  42. package/src/mcp/redact.js +26 -0
  43. package/src/mcp/tools.js +155 -14
  44. package/src/posture/CLAUDE.md +19 -7
  45. package/src/posture/accuracy-scorecard.js +9 -1
  46. package/src/posture/aibom.js +12 -8
  47. package/src/posture/auditor-walkthrough.js +102 -3
  48. package/src/posture/autopilot.js +8 -1
  49. package/src/posture/calibration-drift.js +11 -5
  50. package/src/posture/calibration.js +24 -2
  51. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  52. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  53. package/src/posture/compliance-policy.js +33 -1
  54. package/src/posture/confidence.js +44 -10
  55. package/src/posture/corpus-enroll.js +9 -5
  56. package/src/posture/corpus-match.js +19 -0
  57. package/src/posture/csharp-analysis.js +62 -3
  58. package/src/posture/deploy-platform.js +4 -1
  59. package/src/posture/drift.js +7 -1
  60. package/src/posture/epss.js +13 -1
  61. package/src/posture/evidence-bundle.js +36 -6
  62. package/src/posture/exploitability-probability.js +13 -1
  63. package/src/posture/falsification.js +23 -2
  64. package/src/posture/fix-metrics.js +1 -1
  65. package/src/posture/fix-verify-loop.js +10 -1
  66. package/src/posture/iac-reachability.js +14 -8
  67. package/src/posture/integrity.js +25 -7
  68. package/src/posture/model-rescan.js +65 -0
  69. package/src/posture/mttr.js +5 -0
  70. package/src/posture/poc-inprocess.js +27 -8
  71. package/src/posture/regression-test-gen.js +23 -8
  72. package/src/posture/reverse-blast-radius.js +5 -1
  73. package/src/posture/risk-dollars.js +18 -1
  74. package/src/posture/secret-history.js +20 -11
  75. package/src/posture/security-trend.js +7 -1
  76. package/src/posture/stack-playbook.js +22 -1
  77. package/src/posture/threat-model-grounding.js +2 -2
  78. package/src/posture/validator-metrics.js +10 -3
  79. package/src/posture/verifier.js +32 -57
  80. package/src/report/index.js +183 -14
  81. package/src/runScan.js +1 -1
  82. package/src/sast/_comment-strip.js +15 -4
  83. package/src/sast/_secret-entropy.js +1 -1
  84. package/src/sast/authz.js +6 -4
  85. package/src/sast/bench-shape/index.js +2 -7
  86. package/src/sast/claude-md-prompt-injection.js +14 -3
  87. package/src/sast/cloud-iam.js +60 -7
  88. package/src/sast/cpp-bench-extras.js +1 -1
  89. package/src/sast/csrf.js +7 -5
  90. package/src/sast/env-hygiene.js +5 -2
  91. package/src/sast/iac-terraform.js +25 -0
  92. package/src/sast/java-bench-extras.js +1 -1
  93. package/src/sast/java-constant-fold.js +5 -5
  94. package/src/sast/llm-owasp.js +4 -2
  95. package/src/sast/mcp-audit.js +7 -0
  96. package/src/sast/pipeline.js +8 -0
  97. package/src/sast/prompt-template.js +8 -6
  98. package/src/sast/prototype-pollution.js +6 -2
  99. package/src/sast/redos-nfa.js +6 -6
  100. package/src/sast/secret-concat.js +13 -2
  101. package/src/sast/ssrf-cloud-metadata.js +6 -3
  102. package/src/sast/xss-reflected-multilang.js +1 -1
  103. package/src/sast/xxe.js +1 -1
  104. package/src/sca/CLAUDE.md +3 -4
  105. package/src/sca/container.js +35 -3
  106. package/src/sca/dep-confusion.js +7 -0
  107. package/src/sca/sarif-ingest.js +0 -187
package/CHANGELOG.md CHANGED
@@ -9,6 +9,242 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.136.9 — the real bug: the single-file bundle was never actually self-contained
13
+
14
+ 0.136.8's diagnostic logging answered the question immediately:
15
+
16
+ ```
17
+ Error [ERR_MODULE_NOT_FOUND]: Cannot find module
18
+ '.../scanner/dist/449.index.js' imported from
19
+ '.../scanner/dist/agentic-security.mjs'
20
+ ```
21
+
22
+ `dist/agentic-security.mjs` is documented and committed as a self-contained
23
+ single-file bundle — `.gitignore`'s own comment says the reusable
24
+ `scan.yml` workflow fetches *only that one file* from
25
+ raw.githubusercontent.com so downstream users need no install step. That
26
+ was never quite true. `bin/agentic-security.js` lazily loads each
27
+ subcommand's implementation with `await import('../src/...')` — about 58
28
+ call sites, deliberately, so running `agentic-security scan` doesn't pay
29
+ the cost of loading every posture/discovery/compliance module `agentic-security
30
+ compliance` or `agentic-security hunt` would need. `ncc` code-splits every
31
+ one of those into its own `dist/NNN.index.js` chunk rather than inlining
32
+ it, and the chunk is loaded at runtime via a path relative to the bundle's
33
+ own location on disk — but `.gitignore` only ever allowlisted the main
34
+ `.mjs`, its sha256 sidecar, and the compliance-frameworks data. All 38
35
+ chunk files were silently gitignored. `.claude/settings.local.json`-style
36
+ invisible: they exist on any machine that has ever run `npm run build`
37
+ locally (which is every contributor's, permanently, from the first
38
+ `npm install`), so nobody — human or gate — had a checkout that lacked
39
+ them until this release's hosted CI runs did.
40
+
41
+ This is the third occurrence of the identical bug class in this file's own
42
+ history — `.gitignore` already carries a comment about the same thing
43
+ happening to `compliance-frameworks/` data before ("the shipped CLI
44
+ silently listed ZERO frameworks and exited 0"). Same shape both times: a
45
+ single tracked entry point quietly depends on sibling files nobody
46
+ allowlisted.
47
+
48
+ Fixed by allowlisting `scanner/dist/*.index.js` and committing all 38
49
+ current chunks (744 KB), and by adding a permanent regression guard
50
+ (`test/dist-chunks-tracked.test.js`, wired into `test:lifecycle`) that
51
+ diffs `dist/*.index.js` on disk against `git ls-files dist` and fails
52
+ loud, by name, the moment a future build produces a chunk nobody
53
+ allowlisted — rather than waiting for a lazy code path to hit it in
54
+ production. Verified RED against the pre-fix git state (all 38 chunks
55
+ correctly reported untracked) and GREEN after staging them.
56
+
57
+ ## 0.136.8 — instrumenting a real, still-unexplained hosted-CI-only failure
58
+
59
+ 0.136.7's hosted release run got past every other check and failed on
60
+ exactly one: `test/mttr.test.js`'s CLI-wiring test, which asserts a real
61
+ `agentic-security scan` reports an `mttr` field in `last-scan.json`. That
62
+ field comes from a deliberately best-effort code path in `cmdScan`
63
+ (`bin/agentic-security.js`) wrapped in a bare `catch { /* MTTR is
64
+ best-effort — never block a scan write */ }` — correct as a product
65
+ decision (a scan must never fail because a secondary metric couldn't be
66
+ computed), but it means whatever throws inside that block has never been
67
+ visible to anyone, including this investigation.
68
+
69
+ This is now confirmed NOT the shared-runner flakiness 0.136.5/0.136.6
70
+ blamed it as: reran the identical failed job against the identical commit
71
+ (`gh run rerun --failed`, no code change) and got the exact same failure,
72
+ same file, same line, same message, a fourth consecutive time. Extensive
73
+ local reproduction attempts — simulating `CI=true`/`GITHUB_ACTIONS=true`,
74
+ running the full 1328-test `test:posture` scope concurrently to match CI's
75
+ exact invocation shape, invoking the real bundled CLI directly under the
76
+ same env vars — all passed cleanly. Whatever this is, it is deterministic
77
+ on GitHub's hosted runner and has not reproduced anywhere else tried so far.
78
+
79
+ Rather than keep guessing, the silent catch now logs the actual error to
80
+ stderr whenever `CI`/`GITHUB_ACTIONS` is set (or `AGENTIC_SECURITY_MTTR_DEBUG=1`
81
+ locally), and the test's own assertion message now includes the scan
82
+ subprocess's stderr. Scan behavior is unchanged — this is instrumentation
83
+ only, shipped specifically to get a real answer out of the next hosted run
84
+ instead of another guess.
85
+
86
+ ## 0.136.7 — the doc-drift checker was resolving paths outside the repo
87
+
88
+ 0.136.6's hosted release run failed on a check this same audit added a few
89
+ releases ago: `check-doc-drift.mjs`'s own regression test, "the real
90
+ repository currently has zero mechanically-checkable drift," flagged root
91
+ CLAUDE.md's documented (and correctly gitignored) `.claude/settings.local.json`
92
+ reference as a dangling path.
93
+
94
+ The actual defect was in `resolveCandidate`'s search bases, not in the
95
+ reference: for a CLAUDE.md near the repo root, one of the fallback bases is
96
+ "two directories up from the CLAUDE.md's own directory" — meant for nested
97
+ CLAUDE.md files reaching back toward the repo root, but for the ROOT
98
+ CLAUDE.md itself that lands OUTSIDE the checkout entirely, in whatever
99
+ happens to be the parent of wherever the repo was cloned. On the
100
+ maintainer's laptop that's their home directory, which happens to contain
101
+ an unrelated, machine-global `~/.claude/settings.local.json` from ordinary
102
+ Claude Code usage — so the checker "resolved" the reference against a file
103
+ that has nothing to do with this project, passed locally, and failed on a
104
+ clean CI checkout where no such coincidence exists. `resolveCandidate` now
105
+ rejects any candidate base or resolved path outside the repo root, and
106
+ `settings.local.json` — genuinely optional, gitignored, user-created — joins
107
+ the checker's existing known-example-basename allowlist as a second,
108
+ independent fix.
109
+
110
+ Reproduced without touching CI: moved the local override file aside,
111
+ confirmed the existing regression test still passed (proving the checker was
112
+ resolving against something else entirely), traced it to the home-directory
113
+ escape, fixed both the escape and the allowlist gap, verified clean with the
114
+ file present and absent, then restored it.
115
+
116
+ ## 0.136.6 — correction: the "flaky" dataflow tests were a real, deterministic gap
117
+
118
+ 0.136.5's entry below called a cluster of `test:dataflow` failures on the
119
+ hosted runner resource-contention flakiness, because a second, simultaneous
120
+ workflow run of the identical commit showed a completely different failure
121
+ and neither reproduced locally. That diagnosis was wrong, and the actual
122
+ cause is more interesting: 13 test files pass `runScan(dir, { deep: true })`
123
+ to exercise the interprocedural taint engine directly, but never opt into
124
+ `AGENTIC_SECURITY_DEEP_IN_CI` — and `engine.js` deliberately auto-disables
125
+ deep mode under any detected CI environment unless that second flag is also
126
+ set, precisely so a pathological file can't hang a CI pipeline. Every one of
127
+ those 13 files has therefore been silently CI-broken (never actually
128
+ exercising the deep engine there, only recording the "skipped in CI"
129
+ placeholder finding) since the day it was written — invisible until this
130
+ release's tag push put 62 accumulated commits through hosted CI for the
131
+ first time. Reproduced deterministically with `CI=true node --test
132
+ <file>.test.js` locally (no CI service required), fixed by threading
133
+ `deepInCi: true` alongside `deep: true` at all 21 call sites across those 13
134
+ files, and confirmed both directions: `npm test` is clean with `CI` unset
135
+ and with `CI=true` set.
136
+
137
+ The earlier flakiness diagnosis wasn't entirely wrong — the *other* workflow
138
+ run's single MTTR-wiring failure genuinely didn't reproduce on a third run
139
+ and remains unexplained — but it was wrong about *this* failure cluster,
140
+ which was 100% reproducible once isolated with the right environment
141
+ variable rather than blamed on shared-runner load.
142
+
143
+ ## 0.136.5 — the release workflow gets its own missing dependency
144
+
145
+ 0.136.4 was tagged and its release workflow ran — for the first time ever,
146
+ since the 60-odd commits it carried had accumulated across many local
147
+ sessions without a single hosted-CI push. The gate caught a real gap in
148
+ itself: `nist-catalog-freshness` (a check this same release adds, see below)
149
+ shells out to `scripts/nist-compliance/build-catalog.py`, which needs
150
+ `openpyxl` to read the source workbook. That's present on the maintainer's
151
+ machine via a system Python install, but nothing installs it on the hosted
152
+ runner — so the check could never have passed there. `release.yml` now
153
+ installs it explicitly before the gate runs.
154
+
155
+ The same CI run also showed roughly a dozen dataflow tests fail — a
156
+ completely different set than the *other* workflow (`ci.yml`) failed on the
157
+ same commit at the same time (one flaky MTTR-wiring test, no overlap with the
158
+ dataflow set). Neither set reproduces locally, isolated or otherwise. That
159
+ non-overlap is the signature of resource-contention flakiness on a shared
160
+ runner, not a deterministic regression, so it isn't chased further here — but
161
+ it's worth knowing about if a future release gate flakes on `npm test` again.
162
+
163
+ Per the project's own precedent (see 0.136.1 below): a tag that failed to
164
+ publish stays where it is rather than being moved. 0.136.4 is that tag. This
165
+ ships as 0.136.5 instead, otherwise identical.
166
+
167
+ ## 0.136.4 — dominance-correct implicit-flow taint, and wiring three dead-reachable tools
168
+
169
+ A capability-PRD follow-up audit found several places where a real, tested
170
+ mechanism existed in the code but nothing in the product could actually reach
171
+ it. This release closes those gaps rather than documenting them further.
172
+
173
+ **`implicit-flow.js` now uses real dominance, not a depth counter.** The
174
+ branch-scoping check for implicit taint (does a constant-argument sink sit
175
+ *inside* a tainted branch, or after it closes?) was a path-dependent DFS
176
+ depth-counter, which cannot distinguish a join point from a nested branch and
177
+ both over- and under-attributes depending on CFG shape. It's replaced with a
178
+ proper dominance check (`ssa.js`'s `computeDominators`, already used for φ-node
179
+ placement, now exported and reused here) plus a predecessor-count "sole
180
+ parent" test — needed because an else-less `if`'s CFG lowering links the
181
+ condition directly to the join node, which a naive dominance check alone would
182
+ still misread as a branch root. `dataflow/engine.js`'s implicit-flow post-pass
183
+ is split into two correctly-scoped passes: a sink-call-must-be-inside-the-
184
+ branch check for constant-argument leaks, and an ungated check for
185
+ already-tainted-variable-as-argument leaks, which don't need the sink itself
186
+ to be branch-local.
187
+
188
+ **Three previously-dead mechanisms are now reachable.** `verify_fix`'s PoC-
189
+ recheck leg always reported `not-requested` — the caller never had the PoC to
190
+ pass it, so it's now looked up server-side from the finding's own
191
+ `last-scan.json` entry. `fix-honesty-gate.js`'s deterministic honesty checks
192
+ (vague-assurance residual prose, unbacked false-positive verdicts) were fully
193
+ built and consulted by the verifier, but `fixMeta` was never in `apply_fix` or
194
+ `verify_fix`'s MCP schema, so no caller could ever supply one; a dishonest
195
+ `fixMeta` now blocks the write itself, not just the report. `verifyRunAttestation`
196
+ had no CLI caller at all — `verify-attestation` now auto-detects a
197
+ run-attestation shape and re-scans the target to check it reproduces the
198
+ attested digest, backed by two new release-gate checks
199
+ (`attestation-self-check`, `nist-catalog-freshness`).
200
+
201
+ **Stale docs, fixed instead of flagged.** `docs/compliance/{nist-ai-600-1,
202
+ owasp-asvs}-coverage.md` carried static control tables that drifted from the
203
+ live evaluator; both now point at the `/compliance` walkthrough/report instead
204
+ of duplicating data that can go stale. The ASVS doc also had a genuine
205
+ version mismatch (4.0.3 vs. the 5.0 catalog actually in use). A new
206
+ `scripts/check-doc-drift.mjs` catches this class of staleness mechanically —
207
+ it resolves every backtick-quoted path/export reference in a CLAUDE.md file
208
+ against the real filesystem — after this audit found several by hand.
209
+
210
+ **CVE-replay corpus:** four new capability entries (IaC open-ingress, LLM
211
+ system-prompt injection, MCP untrusted-install, API missing-auth/BFLA),
212
+ closing four of six previously-flagged zero-coverage categories; each verified
213
+ `pre:TP post:TN` against the real runner before joining the baseline. SBOM and
214
+ SCA-reachability are documented in `bench/cve-replay/CONTRIBUTING.md` as
215
+ structurally unable to fit this corpus's binary presence/absence schema —
216
+ they're covered by their own test suites instead.
217
+
218
+ CMP-1's family-alias table also closed three more gaps
219
+ (`k8s-pod-security-privileged`, `mcp-audit.js`'s `agent-tool-exec` backfill,
220
+ dependency-confusion family tagging).
221
+
222
+ **No functional change from 0.136.2.** This version exists for one reason, and
223
+ it is worth stating plainly rather than dressing up: 0.136.2 reached npm from a
224
+ maintainer's laptop, not from CI, so it carries **no provenance attestation** —
225
+ nothing ties that tarball to this repository or this commit beyond trust in the
226
+ publisher.
227
+
228
+ npm will not accept a re-publish of an existing version, so obtaining provenance
229
+ requires a new one. 0.136.3 is that, and nothing else.
230
+
231
+ The release path itself was already proven end to end on 0.136.2: the gate passed
232
+ on a clean runner, npm signed a provenance statement and recorded it in the
233
+ Sigstore transparency log, and only the final registry upload was rejected —
234
+ because the token in CI was not authorized to publish. With a valid automation
235
+ token that last step completes, and the attestation that was already being
236
+ generated actually lands.
237
+
238
+ Verify it yourself once published:
239
+
240
+ ```
241
+ npm view @clear-capabilities/agentic-security-scanner@0.136.3 --json | jq .dist.attestations
242
+ ```
243
+
244
+ `null` means it went out unattested again. `dist.signatures` is NOT the same
245
+ thing — the registry signs every package it serves; provenance is the separate
246
+ Sigstore statement binding the artifact to its source.
247
+
12
248
  ## 0.136.2 — authenticate the gate on the path that actually runs it
13
249
 
14
250
  0.136.1 removed the self-deadlock and the release workflow got further: every
@@ -14,7 +14,6 @@ import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
14
14
  import { toPBOM } from '../src/sast/pipeline.js';
15
15
  import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
16
16
  import { recordScan, formatStreakLine, formatGradeDelta } from '../src/posture/streak.js';
17
- import { ingestAndMerge } from '../src/sca/sarif-ingest.js';
18
17
  import { loadProfile, saveProfile, detectProfile, renderAttributionLine, ATTRIBUTION, ATTRIBUTION_URL } from '../src/posture/profile.js';
19
18
  import { applySuppressions, addSoftAcceptance, expiredSoftAcceptances } from '../src/posture/suppressions.js';
20
19
  import { applyOverrides, validateOverrides, suppressionReport, renderSuppressionSummary } from '../src/posture/rule-overrides.js';
@@ -28,7 +27,6 @@ import { syncTickets } from '../src/integrations/tickets.js';
28
27
  import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
29
28
  import * as triage from '../src/posture/triage.js';
30
29
  import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
31
- import { globFiles } from '../src/util/glob.js';
32
30
 
33
31
  import { stateDir, statePath } from '../src/posture/state-dir.js';
34
32
  // last-scan.json integrity helpers — implementation in posture/integrity.js
@@ -111,7 +109,6 @@ Options:
111
109
  --show-threat-model Append the auto-derived STRIDE threat model summary
112
110
  --show-drift Append calibration-drift alarms (overconfidence detection)
113
111
  --sca-reachable-only Only SCA findings where the vulnerable function is reachable
114
- --ingest-sarif <glob> Merge external SARIF into this scan
115
112
  --scorecard Enrich components with OSSF Scorecard scores
116
113
  --no-network Skip OSV/registry queries (offline mode)
117
114
  --pr [ref] Diff-aware: scan only files changed since ref (auto-detects PR base)
@@ -297,7 +294,7 @@ function _bundleSha() {
297
294
  return 'unavailable';
298
295
  }
299
296
 
300
- async function writeMachineOutput(targetAbs, scan, meta, profile) {
297
+ async function writeMachineOutput(targetAbs, scan, meta, profile, args) {
301
298
  const stateDirPath = stateDir(targetAbs);
302
299
  const { isSafeStateDir: _isSafe, stateWritesEnabled: _writesOn } = await import('../src/posture/state-dir.js');
303
300
  // Read-only scan (NON_MUTATING_SCAN_PRD S1). These writes bypass
@@ -311,7 +308,7 @@ async function writeMachineOutput(targetAbs, scan, meta, profile) {
311
308
  // Always JSON (used by /security-fix and /security-report).
312
309
  await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
313
310
  JSON.stringify(toJSON(scan, meta), null, 2));
314
- if (profile.profile === 'pro' || profile.machineOutput) {
311
+ if (profile.profile === 'pro' || profile.machineOutput || (args && args.flags['machine-output'])) {
315
312
  await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
316
313
  JSON.stringify(toSARIF(scan, meta), null, 2));
317
314
  await fsp.writeFile(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
@@ -440,9 +437,16 @@ async function cmdScan(args) {
440
437
 
441
438
  const only = args.flags.only;
442
439
  if (only) {
440
+ // Four channels exist (scanner/CLAUDE.md): findings (SAST), secrets,
441
+ // supplyChain (SCA), logicVulns (business-logic). --only sast keeps
442
+ // logicVulns (business-logic is source analysis, part of the SAST
443
+ // pillar); --only sca/secrets must also clear it — it's neither SCA
444
+ // nor secrets, and normalizeFindings()/exitCodeFor() both fold it in,
445
+ // so leaving it meant a business-logic finding leaked into a
446
+ // single-pillar scan's output AND its exit code.
443
447
  if (only === 'sast') { scan.secrets = []; scan.supplyChain = []; }
444
- if (only === 'sca') { scan.findings = []; scan.secrets = []; }
445
- if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; }
448
+ if (only === 'sca') { scan.findings = []; scan.secrets = []; scan.logicVulns = []; }
449
+ if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; scan.logicVulns = []; }
446
450
  }
447
451
 
448
452
  // --set-baseline: save current findings as baseline for future --since-baseline filtering
@@ -494,18 +498,6 @@ async function cmdScan(args) {
494
498
  // 0.9.0 Feat-18: --scorecard flag enables OSSF Scorecard enrichment
495
499
  if (args.flags['scorecard']) process.env.AGENTIC_SECURITY_SCORECARD = '1';
496
500
 
497
- // 0.7.0 Feat-7: --ingest-sarif <path-or-glob> merges SARIF from external tools (Semgrep,
498
- // gitleaks, Bandit, Trivy, Checkov, etc.) into this scan's findings, deduping by
499
- // fingerprint and tracking provenance via sources[].
500
- if (args.flags['ingest-sarif']) {
501
- const glob = args.flags['ingest-sarif'];
502
- const paths = await globFiles(glob);
503
- if (paths.length) {
504
- const r = ingestAndMerge(scan, paths);
505
- if (process.stderr.isTTY) process.stderr.write(`[ingest] merged ${r.merged} / added ${r.added} findings from ${paths.length} SARIF file(s)\n`);
506
- }
507
- }
508
-
509
501
  // 0.6.0 Feat-1: --sca-reachable-only filters to only SCA findings where the vulnerable
510
502
  // function was confirmed reachable from a route handler.
511
503
  if (args.flags['sca-reachable-only']) {
@@ -566,6 +558,31 @@ async function cmdScan(args) {
566
558
  try { await enrichWithEPSS(scan); } catch {}
567
559
  }
568
560
 
561
+ // Re-run risk-dollars now that both signals it needs are actually
562
+ // available: EPSS (just enriched above — engine.js's own call ran before
563
+ // this, so every finding's epssScore was still unset) and relevanceTier
564
+ // (posture/relevance.js runs last inside runScan/engine.js, so it's
565
+ // populated on `scan.findings` by the time we get here, but wasn't yet
566
+ // when engine.js's own annotateRiskDollars call ran mid-pipeline).
567
+ try {
568
+ const { annotateRiskDollars } = await import('../src/posture/risk-dollars.js');
569
+ annotateRiskDollars(targetAbs, scan.findings || []);
570
+ } catch {}
571
+
572
+ // Re-run composite risk for the same reason as annotateRiskDollars above:
573
+ // engine.js's mid-pipeline annotateCompositeRisk calls ran before
574
+ // enrichWithEPSS (just above) had a chance to set f.exploitedNow, so
575
+ // every finding's 'exploited-now-floor:75' boost was computed against a
576
+ // field that was always undefined at the time — an actively-exploited
577
+ // CVE (EPSS percentile >= 0.95) never got its compositeRisk score raised.
578
+ // annotateCompositeRisk is pure/idempotent (only sets compositeRisk*
579
+ // fields, reads everything else), so re-running it here is safe.
580
+ try {
581
+ const { annotateCompositeRisk } = await import('../src/posture/composite-risk.js');
582
+ annotateCompositeRisk(scan.findings || []);
583
+ annotateCompositeRisk(scan.supplyChain || []);
584
+ } catch {}
585
+
569
586
  // Blast-radius narrative — purely local, always safe to run.
570
587
  if (!args.flags['no-blast-radius']) {
571
588
  try { enrichWithBlastRadius(scan, targetAbs); } catch {}
@@ -622,7 +639,7 @@ async function cmdScan(args) {
622
639
  } catch { /* attestation is metadata; never fail a scan over it */ }
623
640
 
624
641
  // R2: Always emit machine-readable artifacts to .agentic-security/.
625
- await writeMachineOutput(targetAbs, scan, meta, profile);
642
+ await writeMachineOutput(targetAbs, scan, meta, profile, args);
626
643
 
627
644
  const includeSuppressed = !!args.flags['include-suppressed'];
628
645
  let body;
@@ -664,32 +681,70 @@ async function cmdScan(args) {
664
681
  // Persist last scan for /security-fix and /security-report
665
682
  const { isSafeStateDir: _isSafeStateDir, stateWritesEnabled: _writesOnScan } = await import('../src/posture/state-dir.js');
666
683
  const stateDirPath = stateDir(path.resolve(target));
684
+ // S7 (Stage-0 audit): declared here, not with `const` inside the `if`
685
+ // below — it used to be block-scoped there while recordScan() further
686
+ // down referenced it unconditionally, throwing a ReferenceError on every
687
+ // single scan (silently swallowed by that call's empty catch{}). streak.json
688
+ // — grades, streak days, achievements — never actually persisted through
689
+ // the real CLI as a result, reproduced live before this fix.
690
+ let persistedScan = null;
667
691
  if (_writesOnScan() && _isSafeStateDir(stateDirPath)) {
668
692
  await fsp.mkdir(stateDirPath, { recursive: true });
669
- const persistedScan = toJSON(scan, meta);
693
+ persistedScan = toJSON(scan, meta);
670
694
  // #10 — MTTR: stamp firstSeenAt/lastSeenAt/ageDays from the PREVIOUS scan so
671
695
  // every finding carries an age, SLA breaches can be surfaced, and the fix
672
696
  // loop can report time-to-clean. Best-effort; skipped under --deterministic
673
697
  // so deterministic state stays byte-identical run-to-run.
674
698
  if (!args.flags.deterministic) {
675
699
  try {
676
- const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary } = await import('../src/posture/mttr.js');
700
+ const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary, fingerprintFinding, computeMTTR } = await import('../src/posture/mttr.js');
677
701
  let baselineMap = new Map();
702
+ let prevAll = [];
678
703
  try {
679
704
  const prev = JSON.parse(await fsp.readFile(path.join(stateDirPath, 'last-scan.json'), 'utf8'));
680
705
  baselineMap = buildBaselineMap(prev);
706
+ // The previous scan's own findings already carry firstSeenAt/lastSeenAt
707
+ // from ITS baseline lookup — exactly the shape computeMTTR needs for
708
+ // whichever of them are no longer present now (i.e. were fixed).
709
+ prevAll = [
710
+ ...(prev?.findings || []), ...(prev?.secrets || []),
711
+ ...(prev?.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
712
+ ];
681
713
  } catch { /* first run — empty baseline, everything is firstSeen now */ }
682
714
  const now = Date.now();
683
715
  stampFindingTimestamps(persistedScan.findings || [], baselineMap, now);
684
716
  stampFindingTimestamps(persistedScan.secrets || [], baselineMap, now);
685
717
  stampFindingTimestamps((persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'), baselineMap, now);
718
+ // #10 — MTTR: which of the previous scan's findings are no longer
719
+ // present now (fixed), and how long each took. computeMTTR itself was
720
+ // fully built and tested but had never had a real caller — the module's
721
+ // own comment calls it "true MTTR," distinct from the open-backlog
722
+ // median-age proxy renderSlaSummary already surfaces below.
723
+ const currentFps = new Set([
724
+ ...(persistedScan.findings || []), ...(persistedScan.secrets || []),
725
+ ...(persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
726
+ ].map(fingerprintFinding));
727
+ const removed = prevAll.filter(f => !currentFps.has(fingerprintFinding(f)));
728
+ persistedScan.mttr = computeMTTR(removed);
686
729
  // Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
687
730
  const isJson = format === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
688
731
  if (!isJson) {
689
732
  const sla = renderSlaSummary(persistedScan.findings || []);
690
733
  if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
734
+ if (persistedScan.mttr.count > 0) {
735
+ process.stderr.write(`✅ agentic-security: ${persistedScan.mttr.count} finding(s) fixed since last scan, median ${Math.round(persistedScan.mttr.medianDays)}d to remediate\n`);
736
+ }
691
737
  }
692
- } catch { /* MTTR is best-effort — never block a scan write */ }
738
+ } catch (e) {
739
+ // MTTR is best-effort — never block a scan write. But a silent catch
740
+ // here previously hid a real, deterministic CI-only failure (the
741
+ // mttr field went missing on every hosted-CI run, never locally, and
742
+ // nothing explained why) for long enough that it shipped several
743
+ // releases undiagnosed. Surface it — never fail the scan on it.
744
+ if (process.env.AGENTIC_SECURITY_MTTR_DEBUG === '1' || process.env.CI || process.env.GITHUB_ACTIONS) {
745
+ process.stderr.write(`agentic-security: MTTR computation failed (best-effort, scan unaffected): ${(e && e.stack) || e}\n`);
746
+ }
747
+ }
693
748
 
694
749
  // R5 — report the observed time-to-validated-fix distribution from the
695
750
  // fix attempts recorded by `verifyFix`. This is measurement, not
@@ -735,10 +790,17 @@ async function cmdScan(args) {
735
790
 
736
791
  // 0.14.0 — update streak / achievements after every full scan. Suppress
737
792
  // streak side effects when the user only wants raw JSON output (CI piping).
793
+ // Gated on `persistedScan !== null` (i.e. the same _writesOnScan() &&
794
+ // _isSafeStateDir() condition above) — recordScan()'s own isSafeStateDir
795
+ // check alone does not know about --no-state / AGENTIC_SECURITY_NO_STATE,
796
+ // so without this a "read-only, mutates nothing" scan would still write
797
+ // streak.json. This was accidentally masked before the persistedScan
798
+ // scoping fix (recordScan was never reached at all, for the wrong reason);
799
+ // fixing that reachability bug required adding this guard explicitly.
738
800
  try {
739
- const streak = recordScan(stateDirPath, persistedScan);
801
+ const streak = persistedScan !== null ? recordScan(stateDirPath, persistedScan) : null;
740
802
  // Print celebration / streak line to stderr so it doesn't pollute --format json
741
- if (process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
803
+ if (streak && process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
742
804
  const delta = formatGradeDelta(streak);
743
805
  const line = formatStreakLine(streak);
744
806
  if (delta) process.stderr.write('\n' + delta + '\n');
@@ -814,20 +876,48 @@ async function cmdCi(args) {
814
876
  const packNames = packArg ? (Array.isArray(packArg) ? packArg : String(packArg).split(',')) : [];
815
877
  if (packNames.length) Object.assign(scan, applyPacks(scan, packNames));
816
878
 
817
- // Persist the three CI artifacts.
879
+ // R4 same run attestation cmdScan attaches, computed here too so
880
+ // findings.json (the actual CI evidence artifact) carries the same
881
+ // tamper-evidence digest 'agentic-security scan' does, rather than
882
+ // always shipping attestation: null. Runs after every filter above, so
883
+ // it attests what actually ships. Metadata only — never fails the scan.
884
+ try {
885
+ const { computeRunAttestation } = await import('../src/posture/attestation.js');
886
+ const { keyProvenance } = await import('../src/posture/integrity.js');
887
+ const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
888
+ scan.attestation = computeRunAttestation({
889
+ findings: normalizeFindings(scan),
890
+ engineVersion: PKG_VERSION,
891
+ rulesetVersion: effectiveVersion(targetAbs).version,
892
+ bundleSha: _bundleSha(),
893
+ root: targetAbs,
894
+ sign: true,
895
+ });
896
+ if (scan.attestation) scan.attestation.keyProvenance = keyProvenance();
897
+ } catch { /* attestation is metadata; never fail a scan over it */ }
898
+
899
+ // Persist the three CI artifacts — but a refusal to write must NEVER skip
900
+ // the --fail-on evaluation below. S1 (Stage-0 audit, 2026): this used to be
901
+ // a bare `return;` inside the write-guard, so `cmdCi` returned `undefined`
902
+ // and `process.exit(await cmdCi(args))` became `process.exit(undefined)`,
903
+ // which Node treats as exit 0 — a CI pipeline reading only $? would see a
904
+ // passing build whose findings were never evaluated against --fail-on at
905
+ // all. The write and the gate are orthogonal: "we couldn't persist
906
+ // artifacts" is not evidence about "the scan is clean."
818
907
  const stateDirPath = stateDir(targetAbs);
819
908
  const { isSafeStateDir: _isSafeCi, stateWritesEnabled: _writesOnCi } = await import('../src/posture/state-dir.js');
820
- if (!_writesOnCi() || !_isSafeCi(stateDirPath)) {
909
+ const _canWriteCi = _writesOnCi() && _isSafeCi(stateDirPath);
910
+ if (!_canWriteCi) {
821
911
  if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDirPath} — no project marker\n`);
822
- return;
912
+ } else {
913
+ await fsp.mkdir(stateDirPath, { recursive: true });
914
+ await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
915
+ JSON.stringify(toJSON(scan, meta), null, 2));
916
+ await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
917
+ JSON.stringify(toSARIF(scan, meta), null, 2));
918
+ await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
919
+ toJUnit(scan, meta));
823
920
  }
824
- await fsp.mkdir(stateDirPath, { recursive: true });
825
- await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
826
- JSON.stringify(toJSON(scan, meta), null, 2));
827
- await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
828
- JSON.stringify(toSARIF(scan, meta), null, 2));
829
- await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
830
- toJUnit(scan, meta));
831
921
 
832
922
  const scanCode = exitCodeFor(scan);
833
923
  const findings = normalizeFindings(scan);
@@ -835,7 +925,9 @@ async function cmdCi(args) {
835
925
  for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
836
926
  process.stderr.write(
837
927
  `[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
838
- `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n` +
928
+ (_canWriteCi
929
+ ? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
930
+ : `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
839
931
  `[ci] fail-on=${failOn} scan-exit=${scanCode}\n`
840
932
  );
841
933
  // FR-SDLC-9: when --policy <file.rego> is supplied, evaluate against the
@@ -1758,16 +1850,73 @@ async function cmdAttest(args) {
1758
1850
  return 0;
1759
1851
  }
1760
1852
 
1853
+ // R4/D2 correctness follow-up: attestation.js ships TWO independent
1854
+ // verifiers — verifyEvidenceBundle (per-finding, Ed25519, self-contained —
1855
+ // only needs a public key) and verifyRunAttestation (whole-run, per-install
1856
+ // HMAC, NOT self-contained — needs the caller to also supply the finding
1857
+ // set to re-derive against). This CLI command only ever called the first;
1858
+ // the second had zero production callers anywhere in the repo (confirmed by
1859
+ // grep — only test/attestation.test.js called it). Auto-detect which
1860
+ // artifact was handed in and dispatch to the matching verifier instead of
1861
+ // silently misinterpreting a run-attestation as a (structurally different)
1862
+ // evidence bundle.
1863
+ function _asRunAttestation(obj) {
1864
+ if (obj && typeof obj.digest === 'string' && obj.canonicalisation) return obj;
1865
+ if (obj && obj.attestation && typeof obj.attestation.digest === 'string') return obj.attestation;
1866
+ return null;
1867
+ }
1868
+
1869
+ // Verifies a run attestation the ONLY way it can be verified: by re-deriving
1870
+ // the digest from a fresh scan and comparing. Unlike an evidence bundle
1871
+ // (self-contained, just needs a public key), a run attestation is a claim
1872
+ // about a FINDING SET, not a standalone signed blob — "does scanning this
1873
+ // codebase right now reproduce the exact digest an earlier run attested"
1874
+ // is the actual, meaningful question this command answers.
1875
+ async function cmdVerifyRunAttestation(attestation, args) {
1876
+ const projectPath = path.resolve(args.flags.against || '.');
1877
+ if (!fs.existsSync(projectPath)) {
1878
+ console.error(`--against path does not exist: ${projectPath}`);
1879
+ return 2;
1880
+ }
1881
+ console.log(`Re-scanning ${projectPath} to check it reproduces the attested digest...`);
1882
+ const { runScan } = await import('../src/runScan.js');
1883
+ const { normalizeFindings } = await import('../src/report/index.js');
1884
+ const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
1885
+ const { verifyRunAttestation } = await import('../src/posture/attestation.js');
1886
+ const { scan } = await runScan(projectPath);
1887
+ const r = verifyRunAttestation(attestation, {
1888
+ findings: normalizeFindings(scan),
1889
+ engineVersion: PKG_VERSION,
1890
+ rulesetVersion: effectiveVersion(projectPath).version,
1891
+ bundleSha: _bundleSha(),
1892
+ root: projectPath,
1893
+ });
1894
+ if (!r.ok) {
1895
+ console.error(`✗ INVALID — ${r.reason}`);
1896
+ return 1;
1897
+ }
1898
+ console.log('✓ VALID — a fresh scan of this project reproduces the attested digest exactly.');
1899
+ console.log('');
1900
+ console.log(` digest: ${attestation.digest}`);
1901
+ console.log(` findingCount: ${attestation.findingCount}`);
1902
+ console.log(` proves: ${attestation.proves}`);
1903
+ console.log(` does NOT prove: ${attestation.doesNotProve}`);
1904
+ return 0;
1905
+ }
1906
+
1761
1907
  async function cmdVerifyAttestation(args) {
1762
1908
  const { verifyEvidenceBundle, keyPaths } = await import('../src/posture/evidence-bundle.js');
1763
1909
  // `args._[0]` is the command name itself — the same convention cmdScan uses.
1764
1910
  const file = args.flags.bundle || args._[1];
1765
- if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json> [--public-key <path>]'); return 2; }
1911
+ if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
1766
1912
 
1767
1913
  let bundle;
1768
1914
  try { bundle = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
1769
1915
  catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
1770
1916
 
1917
+ const runAttestation = _asRunAttestation(bundle);
1918
+ if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
1919
+
1771
1920
  const keyFile = args.flags['public-key'] || keyPaths().publicKey;
1772
1921
  let publicKeyPem = null;
1773
1922
  try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
package/dist/113.index.js CHANGED
@@ -78,7 +78,7 @@ function recordFixAttempt(scanRoot, record) {
78
78
  try {
79
79
  const dir = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__/* .stateDir */ .Pn)(scanRoot);
80
80
  if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.isSafeStateDir)(dir)) return false;
81
- if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return;
81
+ if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return false;
82
82
  node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true });
83
83
  // One writeSync of one newline-terminated line: a concurrent reader sees
84
84
  // whole records or nothing, and a torn tail is dropped on read.
@@ -266,7 +266,16 @@ function _detectRunner(scanRoot) {
266
266
  } catch { return null; }
267
267
  })();
268
268
  if (pkg && pkg.scripts && pkg.scripts.test && !/no test specified/.test(String(pkg.scripts.test))) {
269
- return { runner: 'npm', cmd: 'npm', args: ['test', '--silent', '--', '--passWithNoTests'] };
269
+ // --passWithNoTests is Jest-specific CLI syntax appending it
270
+ // unconditionally broke every non-Jest npm test script (mocha, vitest,
271
+ // ava, tap, or a plain node script) with an "unrecognized option" exit,
272
+ // failing verification for a reason that has nothing to do with
273
+ // whether the patch actually broke anything. Only add it when Jest is
274
+ // actually the configured runner.
275
+ const usesJest = /\bjest\b/.test(String(pkg.scripts.test))
276
+ || Boolean(pkg.devDependencies?.jest) || Boolean(pkg.dependencies?.jest);
277
+ const args = usesJest ? ['test', '--silent', '--', '--passWithNoTests'] : ['test', '--silent'];
278
+ return { runner: 'npm', cmd: 'npm', args };
270
279
  }
271
280
  if (has('pytest.ini') || has('pyproject.toml') || has('setup.cfg')) {
272
281
  return { runner: 'pytest', cmd: 'pytest', args: ['-q', '--no-header', '-x'] };
@@ -406,8 +415,8 @@ var external_node_child_process_ = __webpack_require__(1421);
406
415
  var external_node_fs_ = __webpack_require__(3024);
407
416
  // EXTERNAL MODULE: external "node:path"
408
417
  var external_node_path_ = __webpack_require__(6760);
409
- // EXTERNAL MODULE: ./src/engine.js + 499 modules
410
- var engine = __webpack_require__(2912);
418
+ // EXTERNAL MODULE: ./src/engine.js + 196 modules
419
+ var engine = __webpack_require__(2739);
411
420
  ;// CONCATENATED MODULE: ./src/posture/fix-honesty-gate.js
412
421
  // Deterministic honesty gates on fix / finding output (#7).
413
422
  //
package/dist/178.index.js CHANGED
@@ -13,7 +13,7 @@ export const modules = {
13
13
  /* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1421);
14
14
  /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3024);
15
15
  /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
16
- /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(2912);
16
+ /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(2739);
17
17
  // Time-travel + counterfactual scanning (v0.68).
18
18
  //
19
19
  // Two new modes that exploit the pure-input shape of runFullScan: