@clear-capabilities/agentic-security-scanner 0.144.0 → 0.145.0

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 (69) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/bin/agentic-security.js +294 -3
  3. package/dist/113.index.js +11 -3
  4. package/dist/178.index.js +24 -6
  5. package/dist/271.index.js +165 -0
  6. package/dist/384.index.js +1 -1
  7. package/dist/435.index.js +22 -0
  8. package/dist/444.index.js +11 -2
  9. package/dist/449.index.js +76 -12
  10. package/dist/526.index.js +11 -3
  11. package/dist/637.index.js +27 -5
  12. package/dist/970.index.js +65 -1
  13. package/dist/agentic-security.mjs +9 -9
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +14 -8
  16. package/src/compare.js +6 -1
  17. package/src/dataflow/CLAUDE.md +1 -1
  18. package/src/engine.js +488 -29
  19. package/src/fix/apply-fix-service.js +1 -0
  20. package/src/history-scan.js +22 -5
  21. package/src/ir/CLAUDE.md +1 -1
  22. package/src/lsp/server.js +49 -2
  23. package/src/mcp/tools.js +20 -0
  24. package/src/pipeline/assurance-mode.js +64 -1
  25. package/src/pipeline/finding-schema.js +8 -1
  26. package/src/posture/CLAUDE.md +121 -0
  27. package/src/posture/accuracy-scorecard.js +60 -0
  28. package/src/posture/artifact-registry.js +24 -0
  29. package/src/posture/auditor-walkthrough.js +116 -13
  30. package/src/posture/compliance-policy.js +12 -2
  31. package/src/posture/cross-repo-memory.js +7 -2
  32. package/src/posture/fix-history.js +25 -2
  33. package/src/posture/fix-verify.js +9 -1
  34. package/src/posture/fleet.js +0 -0
  35. package/src/posture/git-history.js +13 -5
  36. package/src/posture/material-change.js +21 -2
  37. package/src/posture/mttr.js +75 -12
  38. package/src/posture/pre-incident-archaeology.js +39 -7
  39. package/src/posture/privacy-framework.js +14 -0
  40. package/src/posture/provenance/ai-authorship.js +68 -0
  41. package/src/posture/provenance/branch-entry.js +80 -0
  42. package/src/posture/provenance/cache.js +143 -0
  43. package/src/posture/provenance/confidence.js +36 -0
  44. package/src/posture/provenance/coordinator.js +786 -0
  45. package/src/posture/provenance/dag-walk.js +249 -0
  46. package/src/posture/provenance/evidence-attribution.js +59 -0
  47. package/src/posture/provenance/git-evidence.js +310 -0
  48. package/src/posture/provenance/lifecycle.js +208 -0
  49. package/src/posture/provenance/missing-control-resolver.js +137 -0
  50. package/src/posture/provenance/origin-resolver.js +342 -0
  51. package/src/posture/provenance/predicate-replay.js +133 -0
  52. package/src/posture/provenance/providers/config.js +39 -0
  53. package/src/posture/provenance/providers/github.js +62 -0
  54. package/src/posture/provenance/providers/gitlab.js +58 -0
  55. package/src/posture/provenance/repo-lineage.js +74 -0
  56. package/src/posture/provenance/sca-origin.js +139 -0
  57. package/src/posture/provenance/schema.js +255 -0
  58. package/src/posture/provenance/transitive-sca.js +147 -0
  59. package/src/posture/provenance/validate.js +30 -0
  60. package/src/posture/provenance-evidence-bundle.js +144 -0
  61. package/src/posture/sbom-diff.js +15 -2
  62. package/src/posture/secret-history.js +10 -2
  63. package/src/posture/state-dir.js +38 -14
  64. package/src/posture/vuln-archaeology.js +8 -2
  65. package/src/pr-delta.js +25 -4
  66. package/src/report/index.js +197 -3
  67. package/src/runScan.js +34 -5
  68. package/src/sast/rate-limit.js +33 -3
  69. package/src/util/git-hardening.js +128 -0
package/CHANGELOG.md CHANGED
@@ -11,6 +11,257 @@
11
11
 
12
12
 
13
13
 
14
+ ## 0.145.0 — Finding Provenance ships, and two audits find what the first one missed
15
+
16
+ Every finding now carries a `findingProvenance` record answering "when did this
17
+ enter the codebase, and how sure are we?" — resolved from Git history, not
18
+ guessed. It is **opt-in**: pass `--provenance` (or any provenance flag, e.g.
19
+ `--provenance-since`, `--require-provenance`) on a scan of a Git repository.
20
+
21
+ It is opt-in rather than on-by-default because the release gate measured what
22
+ on-by-default costs: time-to-first-finding over a 207-file tree went 4.5s to
23
+ 45s. That is a 7.6x regression on the one metric this product's own benchmark
24
+ calls the binding constraint for its ICP — "how long until the FIRST useful
25
+ result, not aggregate F1" — and resolving commit history for every finding is
26
+ not what a first-time user is waiting on. CI, compliance and triage callers
27
+ that want provenance ask for it explicitly and pay the cost knowingly.
28
+
29
+ **What it resolves.** For a SAST finding: the commit that introduced it, found
30
+ by replaying the finding's own predicate against historical blobs (`git blame`
31
+ answers "who last touched this line", which is a different and usually wrong
32
+ question) and then confirming the predicate was FALSE in that commit's parent.
33
+ For a direct dependency: the commit that moved the declared version in
34
+ `package.json`/`requirements.txt` into an advisory's vulnerable range. Alongside
35
+ the origin commit: the branch/PR the change entered through, the evidence nodes
36
+ (source/sink/manifest, each as a path:line:commit triple), a confidence level
37
+ with its reasons, and a lifecycle ledger of introduce/remediate/reintroduce
38
+ events at `.agentic-security/provenance/lifecycle.json`.
39
+
40
+ **Only a complete scan can close a finding.** The ledger's remediation pass turns
41
+ a finding's *absence* into the claim "this was fixed," which is sound only when
42
+ the scan actually looked everywhere it could have found it. A `--changed-since` /
43
+ `--pr` scan, or any caller-supplied file list (the MCP `scan_diff` tool, the LSP's
44
+ on-save scan), therefore records new and reintroduced findings normally but closes
45
+ nothing — the entries it did not look at stay open until a full scan says
46
+ otherwise. For the same reason the ledger is written only when the scan target is
47
+ a real directory: a scan of a path that does not exist resolves its state
48
+ directory by walking up from the current working directory, and would otherwise
49
+ write a verdict about a project it never read.
50
+
51
+ **It refuses to guess.** Every record carries a terminal `status`, and there is
52
+ no path that leaves the field absent: `complete`, `partial` (history could not
53
+ confirm a parent boundary — a shallow clone, or an advisory with no `introduced`
54
+ bound), `uncommitted` (the finding exists only in the working tree),
55
+ `not_available`, `budget_exhausted`, or `error`. A shallow clone can never reach
56
+ `complete`. Author emails are redacted from every output format unless
57
+ `--include-author-email` is passed.
58
+
59
+ **New flags** (`agentic-security scan --help` documents all seven):
60
+ `--provenance <standard|deep>`, `--no-provenance`, `--provenance-since <ref>`,
61
+ `--provenance-timeout <ms>`, `--include-author-email`, `--pseudonymize-authors`,
62
+ `--require-provenance`. `deep` mode performs real non-linear DAG analysis (see
63
+ the M3 subsection below) — it is no longer a stub that silently runs `standard`.
64
+ `--require-provenance` reports unresolved provenance as a scan-health condition
65
+ and downgrades `scanHealth.status` to `partial`; it never changes the exit code
66
+ by itself — see the M2 subsection below for the mechanism that does.
67
+ `--verbose --firehose` prints the provenance block per finding in text output.
68
+ See [`docs/guides/finding-provenance.md`](docs/guides/finding-provenance.md)
69
+ for the full user-facing writeup.
70
+
71
+ ### M2: format parity, compliance/MTTR/fix-lifecycle surfacing, and `--assurance strict` can now fail the build
72
+
73
+ M0+M1 landed provenance resolution and the JSON/text surfaces above. M2 threads
74
+ that data through every other output and, for the first time, gives provenance
75
+ completeness the power to fail a CI build.
76
+
77
+ - **Format parity.** SARIF, CSV, Markdown, and HTML output now all carry each
78
+ finding's `findingProvenance`, matching what JSON already had — a consumer no
79
+ longer has to switch formats to see it.
80
+ - **Compliance evidence.** Auditor-walkthrough and NIST Privacy Framework gap
81
+ findings now carry `controlRefs` (the finding ids backing a control's gap
82
+ determination) and `derivedProvenance` (the earliest proven open condition
83
+ among those findings, with a `confidence` level and stated `limitations`).
84
+ - **MTTR.** `mttr.js` reports `ageBasis` (`finding_origin` | `earliest_observable`
85
+ | `uncommitted` | `first_observed`) and `provenAgeDays` alongside the existing
86
+ wall-clock `ageDays`, so age-to-remediate can be read against the commit that
87
+ actually introduced the finding, not just when the scanner first saw it.
88
+ - **Fix records.** Every fix-history entry now carries a `provenanceAtFix`
89
+ snapshot — the finding's `findingProvenance` as it stood at the moment the fix
90
+ was applied — so a later audit can see what was known at fix time, not just
91
+ what is knowable now.
92
+ - **`--assurance strict` can now fail the build over incomplete provenance.**
93
+ This is the behavior change that matters most, and it is a genuinely new
94
+ failure mode, not a rewording of `--require-provenance` above: strict mode
95
+ (`--assurance strict`) now treats any finding whose `findingProvenance.status`
96
+ is outside `[complete, uncommitted]` as making the scan incomplete, and fails
97
+ the same way it already fails on a failed or skipped analyzer. `--require-provenance`
98
+ is unchanged by this — it still only flags and never fails the exit code.
99
+ `--assurance strict` is the new, separate mechanism that does fail it. See the
100
+ `KNOWN INTERACTION` comment in `scanner/src/pipeline/assurance-mode.js` for a
101
+ known, disclosed consequence: transitive `vulnerable_dep`, `unpinned_dep`, and
102
+ `no_lockfile` supply-chain findings are stamped `not_available` today (the
103
+ latter two are a category error — there is no commit that introduced a
104
+ *missing* lockfile — not merely a deferral), so `--assurance strict` will fail
105
+ on nearly any real project with a dependency manifest until that gap closes.
106
+
107
+ ### M3: real non-linear history, transitive-dependency provenance, missing-control regressions, PR/CODEOWNERS enrichment
108
+
109
+ - **`--provenance deep` now does real work.** Instead of the M0–M2 stub that
110
+ accepted the flag and silently ran `standard`, deep mode walks every parent
111
+ of a merge commit (not just the first), which resolves origins standard
112
+ mode's first-parent-only walk cannot see, and detects reverts and
113
+ cherry-picks — surfaced as `findingOrigin.revertOf` / `.cherryPickOf` — via
114
+ a real unified-diff inversion.
115
+ - **Transitive-dependency provenance is now live-wired**, not merely
116
+ modeled: `transitive-sca.js` re-derives lockfile ancestry per historical
117
+ commit to find the commit that moved a *lockfile-resolved* (not
118
+ manifest-declared) dependency into an advisory's vulnerable range.
119
+ - **Missing-control regressions** — a previously-observed safeguard (today:
120
+ `sast/rate-limit.js`'s findings) disappearing — can now resolve a real
121
+ origin via `missing-control-resolver.js`.
122
+ - **Optional GitHub/GitLab PR-metadata + CODEOWNERS enrichment**
123
+ (`findingProvenance.providerEnrichment`) is live for `complete`-status
124
+ findings, configured via `.agentic-security/provenance-providers.yml` /
125
+ `AGENTIC_SECURITY_GITHUB_TOKEN` / `AGENTIC_SECURITY_GITLAB_TOKEN`, capped
126
+ per scan.
127
+
128
+ ### M4: signed provenance evidence bundles, cross-repository lineage, an AI-authorship hook
129
+
130
+ - **`agentic-security attest --provenance`** signs a per-finding provenance
131
+ record (origin commit, confidence, evidence attribution) with the same
132
+ Ed25519 key material as the existing finding-evidence bundle mechanism;
133
+ `verify-attestation` auto-detects and verifies it against a public key
134
+ alone. Note the flag-shape collision with `scan`: on `attest`, `--provenance`
135
+ takes an optional *finding id*, not a mode — `attest --provenance deep`
136
+ looks for a finding literally named `deep`.
137
+ - **Cross-repository lineage.** An operator-declared
138
+ `.agentic-security/repo-lineage.json` can link a root-commit origin (a
139
+ finding whose earliest commit has no parent in the current repo) across a
140
+ prior, local-clone-only fork/split history. Resolution is conservative:
141
+ content at the linked line must actually match, not merely exist, before
142
+ an origin is reported, and the record discloses the boundary crossing
143
+ explicitly.
144
+ - **An extensible AI-authorship verifier registry**
145
+ (`registerAIAuthorshipVerifier` / `resolveAIAuthorship`) now stamps every
146
+ SAST `findingOrigin` with an `aiAuthorship` field; no verifier is
147
+ registered today, so it defaults honestly to `{status:'unknown', verifier:null}`.
148
+ - **Fleet-wide rollups** (`fleet.js`) now surface provenance-proven
149
+ remediation debt. Fleet MTTR is honestly disclosed rather than fabricated:
150
+ real remediation-timing data isn't reachable from the production fleet
151
+ driver without new state, so `rollupFleet` distinguishes "never tracked"
152
+ from "tracked, zero remediations" instead of reporting a misleading number.
153
+
154
+ ### PRD completion: injection hardening, evidence-digest binding, retention split, and the first real coverage/accuracy measurements
155
+
156
+ - Author names and commit summaries are now sanitized against terminal
157
+ control-character and Markdown/HTML injection everywhere they reach a
158
+ human (FR-PROV-026).
159
+ - The run-attestation digest and provenance cache key are now genuinely
160
+ bound to the PRD-named inputs they claim to cover, including
161
+ detector/ruleset version.
162
+ - Symlink-escape protection added to the git evidence layer.
163
+ - **`--pseudonymize-authors`** (new flag, listed above) replaces raw
164
+ commit-author names with a stable `Contributor-XXXXXXXX` id — for when you
165
+ need to compare "who introduced what" without a raw name in the output.
166
+ Honored everywhere `--include-author-email`'s redaction already was,
167
+ including PR-reviewer logins and CODEOWNERS entries from provider
168
+ enrichment.
169
+ - The provenance cache now lives in its own top-level, independently-retained
170
+ state directory (`.agentic-security/provenance-cache/`), split from the
171
+ permanent lifecycle ledger (`.agentic-security/provenance/lifecycle.json`),
172
+ so cache eviction can never touch permanent history.
173
+ - **Two PRD success metrics are now genuinely measured and published, not
174
+ just designed:** known-origin accuracy (12/13 = 92.3% on the labeled
175
+ corpus, against a ≥98% target) and provenance coverage (311/341 = 91.2% on
176
+ this repository's own tree, against a ≥95% target — all 30 shortfall
177
+ findings resolve to `partial`, not `error`/`not_available`, so the gap is
178
+ reduced confidence rather than pipeline failure).
179
+ - `scan.secrets` and blameable `scan.logicVulns` entries now go through real
180
+ origin resolution (real stableIds, real git history) instead of a
181
+ permanent `not_available` placeholder.
182
+
183
+ ### Second-audit remediation: a hostile-repository RCE closed, and further honesty fixes
184
+
185
+ An independent second audit of the completed Finding Provenance PRD found
186
+ one security-critical gap and several places where a metric or a claim
187
+ wasn't as real as it read. All fixed this release:
188
+
189
+ - **Security fix (1 of 2).** Git subprocess calls in the provenance pipeline
190
+ are now hardened against a hostile repository's own `.git/config` (e.g. a
191
+ malicious `core.fsmonitor`), `.gitattributes` `textconv` drivers, and
192
+ external `diff` drivers — a repository could previously trigger arbitrary
193
+ code execution merely by being scanned, provenance on or off. The sweep
194
+ covers every git invocation in the scanner, not just the provenance
195
+ module, and a source-level guard now fails the build if a future `git
196
+ diff` call site omits `--no-ext-diff`. Removing the shell from two
197
+ remaining `execSync` call sites also closed a command-injection path via
198
+ attacker-controlled filenames.
199
+ - **Security fix (2 of 2), found while reviewing a performance optimization
200
+ in this same release.** A change that fused the parent-commit lookup into
201
+ an existing `git show` placed that field after the author name in a
202
+ delimiter-separated record. Git preserves a literal `0x1f` inside an
203
+ author name, so an outside contributor choosing their own author name
204
+ could shift the parse and select the bytes read as the parent commit.
205
+ Because a parent whose blobs cannot be fetched is indistinguishable from
206
+ one that genuinely lacks the finding, this could manufacture a
207
+ `status: 'complete'` origin with **HIGH confidence** for a boundary that
208
+ was never verified — a fabricated certainty claim that would then flow
209
+ into signed evidence bundles. The field now sits behind the commit hash
210
+ only, with hex validation as defense in depth. Both directions are pinned
211
+ by a regression test.
212
+ - The provenance cache key and evidence digest are now genuinely bound to
213
+ the running detector/ruleset version (previously always `null` in
214
+ practice), so upgrading the scanner correctly invalidates stale cached
215
+ provenance.
216
+ - `ageBasis` and `provenAgeDays` are now rendered wherever a finding's age is
217
+ shown, not only written to `last-scan.json`.
218
+ - Provenance coverage is now wired into the real, running scorecard-generation
219
+ path (see "PRD completion" above for the number) instead of always
220
+ reporting "unmeasured."
221
+ - **Performance measurement is now honest end-to-end**, and the honest
222
+ numbers are a real miss against target: real p95 (n=20) over both cold and
223
+ warm cache arms, and a genuine two-sided memory comparison (not a one-arm
224
+ heap delta). Measured this release — cold-cache time ~27x wall-clock p95
225
+ against a ≤1.3x (≤30% overhead) target, warm-cache ~2x; cold-cache memory
226
+ ~13x against a ≤1.2x target. `bench:provenance-accuracy:check` is now
227
+ wired into the pre-push gate so the known-origin-accuracy number can no
228
+ longer silently rot.
229
+ - The PRD's **required compliance-evidence disclaimer** ("Provenance
230
+ establishes repository history for technical evidence. It does not prove
231
+ developer intent, control operation outside code, organizational
232
+ compliance, or certification.") now appears next to every
233
+ provenance-derived origin the auditor walkthrough renders
234
+ (`compliance --walkthrough`), and user-facing documentation for the whole
235
+ feature now exists at
236
+ [`docs/guides/finding-provenance.md`](docs/guides/finding-provenance.md).
237
+ - **Not fixed, disclosed honestly:** a finding in a file renamed after
238
+ introduction still degrades to `status: 'partial'` rather than resolving
239
+ its true pre-rename origin commit. Known and traced, not fixed this round.
240
+
241
+ ### Breaking: SCA finding ids change for manifest-declared dependencies
242
+
243
+ Direct dependencies declared in `package.json` / `requirements.txt` now carry the
244
+ manifest **line number** where they are declared, and `report/index.js`'s
245
+ `fingerprint()` folds that line into the finding id. This was necessary for
246
+ provenance — an SCA finding had no line, so every `vulnerable_dep` from one
247
+ manifest previously hashed to the SAME id and could not be told apart — but it
248
+ means those ids are **not stable across this upgrade**.
249
+
250
+ Concretely: any triage verdict, baseline entry, or suppression keyed on the OLD
251
+ id of a `package.json`- or `requirements.txt`-declared dependency finding will no
252
+ longer match and is effectively orphaned. Affected state:
253
+ `.agentic-security/baseline.json`, triage memory, and `disable:`/suppression
254
+ entries naming an SCA finding id. Transitive dependencies (resolved from a
255
+ lockfile, not declared in a manifest) are unaffected, as are all SAST, secrets,
256
+ and business-logic findings.
257
+
258
+ **There is no automatic migration**, and that is deliberate rather than an
259
+ oversight — id aliasing would have to be carried indefinitely to be safe, and
260
+ the orphaned entries fail open (a finding reappears) rather than closed (a real
261
+ finding stays hidden). Re-triage or re-baseline the affected SCA findings after
262
+ upgrading; `agentic-security scan --set-baseline` regenerates the baseline in one
263
+ step.
264
+
14
265
  ## 0.144.0 — Assurance hardening closes Epic E2, and an independent audit finds what "verified" missed
15
266
 
16
267
  The assurance-hardening PRD (`docs/implementation/assurance-hardening-*`)
@@ -8,6 +8,7 @@ import { createRequire } from 'node:module';
8
8
  const __require = createRequire(import.meta.url);
9
9
  const PKG_VERSION = __require('../package.json').version;
10
10
  import { signLastScan as _signLastScan, verifyLastScan as _verifyLastScanShared } from '../src/posture/integrity.js';
11
+ import { isProvenanceHealthy, sanitizeForTerminal } from '../src/posture/provenance/schema.js';
11
12
  import { runScan } from '../src/runScan.js';
12
13
 
13
14
  // Every command is dispatched as `process.exit(await cmdX(args))`, and
@@ -185,9 +186,20 @@ Options:
185
186
  --no-epss Skip EPSS exploit-prediction enrichment (default: enabled)
186
187
  --no-blast-radius Skip blast-radius / cost framing (default: enabled)
187
188
  --verbose Include fix bodies + taxonomy in CLI output
189
+ (with --firehose, also prints each finding's git-origin provenance)
188
190
  --output <file> Write report to file instead of stdout
189
191
  --machine-output Always write .agentic-security/findings.{sarif,json,csv}
190
192
 
193
+ Finding provenance (which commit introduced each finding):
194
+ --provenance <standard|deep> Resolution depth (default: standard; deep explores non-linear ancestry — merges, reverts, cherry-picks)
195
+ --no-provenance Skip git-history provenance entirely (findings report not_available)
196
+ --provenance-since <ref> Do not walk history earlier than this git ref/commit
197
+ --provenance-timeout <ms> Whole-scan provenance budget in MILLISECONDS (default 60000)
198
+ --include-author-email Keep commit author emails in output (redacted by default)
199
+ --pseudonymize-authors Replace commit author names with a stable Contributor-XXXXXXXX id
200
+ --require-provenance Report unresolved provenance as a scan-health condition
201
+ (downgrades scanHealth.status to 'partial'; never changes the exit code)
202
+
191
203
  Exit codes:
192
204
  0 = clean 1 = low/medium 2 = high 3 = critical 4 = error`;
193
205
 
@@ -393,6 +405,112 @@ function parseArgs(argv) {
393
405
  return args;
394
406
  }
395
407
 
408
+ // The only values `--provenance` accepts. Used both to validate an inline
409
+ // `--provenance=<mode>` and to decide whether a following bare token is this
410
+ // flag's value at all — see the comment inside the parser.
411
+ const PROVENANCE_MODES = new Set(['standard', 'deep']);
412
+
413
+ // Finding Provenance (M0/M1) — CLI flags that set the env vars engine.js
414
+ // (Task 15) and report/index.js (Task 16) already read:
415
+ // AGENTIC_SECURITY_NO_PROVENANCE, AGENTIC_SECURITY_PROVENANCE_MODE,
416
+ // AGENTIC_SECURITY_PROVENANCE_SINCE, AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS,
417
+ // AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL,
418
+ // AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS. `requireProvenance` is consumed
419
+ // directly by cmdScan (post-scan scanHealth augmentation), not via an env var.
420
+ // Kept as a pure function (argv in, plain object out) so it's unit-testable
421
+ // without invoking the CLI dispatch or touching process.env.
422
+ export function parseProvenanceFlags(argv) {
423
+ // OPT-IN, not opt-out (0.145.0). Provenance was on by default through M0-M4,
424
+ // and the release gate caught what that costs: time-to-first-finding on a
425
+ // 207-file tree went 4.5s -> 45s, a 7.6x regression on the ONE metric
426
+ // bench/ttff/runner.mjs's own header calls the binding constraint for this
427
+ // product's vibecoder ICP ("how long until the FIRST useful result, not
428
+ // aggregate F1"). Resolving history for every finding is simply not what a
429
+ // first-time user is waiting for. It stays one flag away, and CI/compliance
430
+ // callers that DO want it pass `--provenance` explicitly.
431
+ //
432
+ // `disabled` therefore starts true, and any provenance-shaped flag flips it
433
+ // off — asking for `--provenance-since` or `--require-provenance` is asking
434
+ // for provenance, and making the user also pass a bare `--provenance`
435
+ // alongside it would be a papercut with no upside.
436
+ const result = { mode: 'standard', since: null, timeoutMs: undefined, includeEmail: false, pseudonymize: false, requireProvenance: false, disabled: true, warning: null };
437
+ const warnings = [];
438
+ for (let i = 0; i < argv.length; i++) {
439
+ const a = argv[i];
440
+ if (typeof a !== 'string' || !a.startsWith('--')) continue;
441
+ // BOTH `--flag value` and `--flag=value`, because parseArgs() above accepts
442
+ // both for every other flag in this CLI and an operator has no way to know
443
+ // this one parser is different. Exact-string matching silently ignored
444
+ // `--provenance=deep` / `--provenance-since=v1.0.0` /
445
+ // `--provenance-timeout=30000` — no warning, defaults quietly used.
446
+ //
447
+ // Split on the FIRST `=` and keep the whole remainder, rather than
448
+ // parseArgs's `split('=', 2)` which drops everything after a second `=`.
449
+ // A git ref (`--provenance-since=refs/tags/v1=rc1`) is a legal value and
450
+ // truncating it would be a worse failure than the one being fixed.
451
+ const eq = a.indexOf('=');
452
+ const key = eq === -1 ? a : a.slice(0, eq);
453
+ const inline = eq === -1 ? undefined : a.slice(eq + 1);
454
+ // `--flag value` consumes the NEXT argv entry only when there is no inline
455
+ // value; a value that itself starts with `--` is another flag, not this
456
+ // one's argument.
457
+ const takeValue = () => {
458
+ if (inline !== undefined) return inline;
459
+ const next = argv[i + 1];
460
+ if (next !== undefined && !String(next).startsWith('--')) { i++; return next; }
461
+ return undefined;
462
+ };
463
+
464
+ if (key === '--no-provenance') result.disabled = true;
465
+ else if (key === '--provenance') {
466
+ result.disabled = false;
467
+ // The SPACE form only claims the next token when it actually names a
468
+ // mode. `--provenance` is legal on its own (provenance is on by default;
469
+ // the flag is how you say "standard, explicitly"), and this CLI's target
470
+ // is nearly always a positional — `scan --provenance ./src` must scan
471
+ // `./src`, not report `unrecognised mode './src'`. parseArgs() does its
472
+ // own positional collection over the same argv, so consuming here never
473
+ // steals the path from it, but the warning would still be a lie.
474
+ //
475
+ // The INLINE form has no such ambiguity: `--provenance=x` can only ever
476
+ // be a mode, so an unknown one is a typo worth naming.
477
+ const v = inline !== undefined
478
+ ? inline
479
+ : (PROVENANCE_MODES.has(String(argv[i + 1])) ? argv[++i] : undefined);
480
+ if (v === 'deep' || v === 'standard' || v === undefined) {
481
+ result.mode = v || 'standard';
482
+ } else {
483
+ warnings.push(`unrecognised --provenance mode '${v}' (expected standard|deep), running standard`);
484
+ }
485
+ } else if (key === '--provenance-since') { result.since = takeValue() ?? null; result.disabled = false; }
486
+ else if (key === '--provenance-timeout') {
487
+ // MILLISECONDS, and validated as such. `parseInt` alone turned
488
+ // `--provenance-timeout 30s` into 30 — a 30-MILLISECOND budget that
489
+ // expires before the first `git blame` returns, so every finding came
490
+ // back `budget_exhausted` and nothing said why. A missing value produced
491
+ // NaN, which `if (_provFlags.timeoutMs)` then discarded silently. Both
492
+ // now warn and fall back to the engine default rather than inventing a
493
+ // budget the operator did not ask for. The unit is fixed by the env var
494
+ // this flag feeds, AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS.
495
+ const raw = takeValue();
496
+ if (raw === undefined) {
497
+ warnings.push('--provenance-timeout requires a value in milliseconds; using the default budget');
498
+ } else if (!/^\d+$/.test(String(raw).trim()) || parseInt(raw, 10) <= 0) {
499
+ warnings.push(`--provenance-timeout expects a positive integer number of MILLISECONDS, got '${raw}'; using the default budget`);
500
+ } else {
501
+ result.timeoutMs = parseInt(raw, 10);
502
+ result.disabled = false;
503
+ }
504
+ }
505
+ else if (key === '--include-author-email') { result.includeEmail = true; result.disabled = false; }
506
+ else if (key === '--pseudonymize-authors') { result.pseudonymize = true; result.disabled = false; }
507
+ else if (key === '--require-provenance') { result.requireProvenance = true; result.disabled = false; }
508
+ }
509
+ // One field, so a caller that prints `warning` cannot drop the second one.
510
+ result.warning = warnings.length ? warnings.join('; ') : null;
511
+ return result;
512
+ }
513
+
396
514
  async function cmdScan(args) {
397
515
  // NON_MUTATING_SCAN_PRD S1 — a scan is an observation; --no-state makes it one.
398
516
  if (args.flags['no-state']) {
@@ -488,12 +606,67 @@ async function cmdScan(args) {
488
606
  process.stderr.write(`[pr-mode] scanning files changed since: ${changedSince}\n`);
489
607
  }
490
608
 
609
+ // Finding Provenance (M0/M1) — translate --provenance/--no-provenance/etc.
610
+ // into the env vars engine.js already reads. Must run before runScan()
611
+ // below, same as the --deep/--no-deep wiring above.
612
+ const _provFlags = parseProvenanceFlags(process.argv.slice(2));
613
+ if (_provFlags.warning) console.error(`Warning: ${_provFlags.warning}`);
614
+ if (_provFlags.disabled) process.env.AGENTIC_SECURITY_NO_PROVENANCE = '1';
615
+ process.env.AGENTIC_SECURITY_PROVENANCE_MODE = _provFlags.mode;
616
+ if (_provFlags.since) process.env.AGENTIC_SECURITY_PROVENANCE_SINCE = _provFlags.since;
617
+ if (_provFlags.timeoutMs) process.env.AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS = String(_provFlags.timeoutMs);
618
+ if (_provFlags.includeEmail) process.env.AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL = '1';
619
+ if (_provFlags.pseudonymize) process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS = '1';
620
+
491
621
  const { scan, meta } = await runScan(target, {
492
622
  changedSince,
493
623
  onProgress: (p) => {
494
624
  if (process.stderr.isTTY) process.stderr.write(`\r[${p.phase}] ${p.current}/${p.total} ${p.file} `);
495
625
  },
496
626
  });
627
+ // --require-provenance: flag (never fail) any finding whose provenance
628
+ // isn't resolved, via scanHealth — deliberately independent of the
629
+ // severity-based exit code computed by exitCodeFor() at the end of this
630
+ // function. 'uncommitted' is a legitimate terminal status (the finding is
631
+ // in a file with no git history yet), not an incomplete one.
632
+ if (_provFlags.requireProvenance) {
633
+ // ALL FOUR channels (scanner/CLAUDE.md: findings / secrets / supplyChain /
634
+ // logicVulns). Checking only `scan.findings` made the flag report a clean
635
+ // bill of provenance health for a scan whose SCA and secrets findings had
636
+ // none — and report/index.js's normalizeFindings ships all four to the
637
+ // user as findings, so "every finding" has to mean all four here too.
638
+ const incomplete = [];
639
+ for (const [channel, bucket] of [
640
+ ['findings', scan.findings], ['secrets', scan.secrets],
641
+ ['supplyChain', scan.supplyChain], ['logicVulns', scan.logicVulns],
642
+ ]) {
643
+ for (const f of (bucket || [])) {
644
+ if (!f || typeof f !== 'object') continue;
645
+ if (isProvenanceHealthy(f.findingProvenance)) continue;
646
+ incomplete.push(f.id || f.stableId || `${channel}:${f.name || f.file || f.type || 'entry'}`);
647
+ }
648
+ }
649
+ if (incomplete.length > 0) {
650
+ // Written the way EVERY other scan-health signal is written — a sentence
651
+ // in `conditions[]` plus a `complete` -> `partial` status demotion (see
652
+ // pipeline/scan-health.js's applyFreshness, the same "patch it on from
653
+ // bin/ after the engine already built scanHealth" pattern). The previous
654
+ // version set a bespoke `scanHealth.provenanceIncomplete` key that NO
655
+ // consumer reads: pipeline/assurance-mode.js and
656
+ // posture/compliance-policy.js both read `status`/`conditions[]` only, so
657
+ // --require-provenance changed no behaviour anywhere. The array is still
658
+ // carried, for a consumer that wants the ids, but it is no longer the
659
+ // only trace.
660
+ const condition = `--require-provenance: ${incomplete.length} finding(s) have unresolved provenance`;
661
+ scan.scanHealth = {
662
+ ...(scan.scanHealth || {}),
663
+ conditions: [...(Array.isArray(scan.scanHealth?.conditions) ? scan.scanHealth.conditions : []), condition],
664
+ status: (scan.scanHealth?.status ?? 'complete') === 'complete' ? 'partial' : scan.scanHealth.status,
665
+ provenanceIncomplete: incomplete,
666
+ };
667
+ }
668
+ }
669
+
497
670
  // The BOM/attestation emitters stamp the producing engine's version into
498
671
  // their metadata; carry the real package version so it can never drift.
499
672
  if (meta && meta.engineVersion == null) meta.engineVersion = PKG_VERSION;
@@ -745,7 +918,20 @@ async function cmdScan(args) {
745
918
  // per-finding list (with inline why-it-matters / how-it-fires / fix depth) so
746
919
  // "Show ALL findings" actually shows them. Add --verbose for full narration + code.
747
920
  if (args.flags.firehose && (!format || format === 'ship' || format === 'summary')) {
748
- body += '\n\n' + toCLI(scan, { verbose });
921
+ // `provenance` reuses --verbose rather than adding a seventh provenance
922
+ // flag: --verbose already means "print the extra per-finding narration"
923
+ // (explainParts's why/how/fix bodies), and the provenance block is exactly
924
+ // that kind of detail. Before this, explainProvenance()/toCLI's
925
+ // `{provenance}` option had NO production caller at all — it was reachable
926
+ // only from its own unit test, so a feature that was built, reviewed and
927
+ // shipped could never be seen by a user.
928
+ //
929
+ // Gated on provenance actually having run, too: with --no-provenance (or
930
+ // AGENTIC_SECURITY_NO_PROVENANCE=1) every finding carries a `not_available`
931
+ // record, and printing five lines of "we did not look" per finding is noise
932
+ // the operator explicitly opted out of.
933
+ const provenanceOn = !_provFlags.disabled && process.env.AGENTIC_SECURITY_NO_PROVENANCE !== '1';
934
+ body += '\n\n' + toCLI(scan, { verbose, provenance: verbose && provenanceOn });
749
935
  }
750
936
 
751
937
  // v3 next-gen — supplementary blocks for human-readable formats. These
@@ -1054,7 +1240,7 @@ async function cmdCi(args) {
1054
1240
  console.error(`[ci] --assurance must be one of: ${ASSURANCE_MODES.join('|')} (got '${assuranceMode}')`);
1055
1241
  return 1;
1056
1242
  }
1057
- const assuranceVerdict = evaluateAssuranceMode(assuranceMode, scan.scanHealth);
1243
+ const assuranceVerdict = evaluateAssuranceMode(assuranceMode, scan.scanHealth, findings);
1058
1244
  if (!assuranceVerdict.ok) {
1059
1245
  console.error(`[ci] assurance gate FAILED (mode=${assuranceMode}): ${assuranceVerdict.reason}`);
1060
1246
  return 1;
@@ -2162,6 +2348,53 @@ async function cmdCompliance(args) {
2162
2348
 
2163
2349
  async function cmdAttest(args) {
2164
2350
  const scanRoot = path.resolve(args.flags.root || '.');
2351
+
2352
+ if (args.flags.provenance) {
2353
+ const {
2354
+ buildProvenanceEvidenceBundle, signProvenanceEvidenceBundle, ensureKeyPair,
2355
+ } = await import('../src/posture/provenance-evidence-bundle.js');
2356
+
2357
+ let scan;
2358
+ try { scan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); }
2359
+ catch { console.error('No .agentic-security/last-scan.json — run a scan first.'); return 2; }
2360
+
2361
+ const findings = scan.findings || [];
2362
+ const wanted = args.flags.provenance === true ? undefined : args.flags.provenance;
2363
+ // `--provenance` alone (boolean flag) attests every finding WITH
2364
+ // findingProvenance present; `--provenance <id>` scopes to one.
2365
+ const subset = (wanted ? findings.filter((f) => f.id === wanted || f.stableId === wanted) : findings)
2366
+ .filter((f) => f.findingProvenance);
2367
+ if (!subset.length) {
2368
+ console.error(wanted ? `No finding matching "${wanted}" with findingProvenance.` : 'No findings with findingProvenance to attest.');
2369
+ return 2;
2370
+ }
2371
+
2372
+ const kp = ensureKeyPair();
2373
+ if (kp.created) console.error(`Generated a new signing key at ${kp.privateKey} (public: ${kp.publicKey}).`);
2374
+
2375
+ const outDir = statePath(scanRoot, 'attestations');
2376
+ fs.mkdirSync(outDir, { recursive: true });
2377
+ // repoIdentity: best-effort, from the same `git remote` lookup other
2378
+ // provenance modules avoid (no such lookup exists yet) — keep it simple,
2379
+ // pass null when unavailable rather than inventing a git-remote reader
2380
+ // here. A future task can enrich this; the field degrades honestly.
2381
+ const meta = { engineVersion: scan.engineVersion || null, repoIdentity: null, head: scan.commit || null };
2382
+
2383
+ let n = 0;
2384
+ for (const f of subset) {
2385
+ const bundle = signProvenanceEvidenceBundle(buildProvenanceEvidenceBundle(f, meta), kp.privateKeyPem);
2386
+ const name = `provenance-${(f.stableId || f.id || `finding-${n}`)}.json`.replace(/[^\w.-]/g, '_');
2387
+ fs.writeFileSync(path.join(outDir, name), JSON.stringify(bundle, null, 2) + '\n');
2388
+ n++;
2389
+ }
2390
+ console.log(`Signed ${n} provenance evidence bundle(s) → ${path.relative(scanRoot, outDir)}/`);
2391
+ console.log(`Public key (share this with whoever verifies): ${kp.publicKey}`);
2392
+ console.log('');
2393
+ console.log('A bundle proves its contents are unmodified since signing. It does NOT');
2394
+ console.log('prove the origin commit is correctly identified — read confidence.level.');
2395
+ return 0;
2396
+ }
2397
+
2165
2398
  const {
2166
2399
  ensureKeyPair, buildEvidenceBundle, signEvidenceBundle,
2167
2400
  } = await import('../src/posture/evidence-bundle.js');
@@ -2302,6 +2535,37 @@ async function cmdVerifyAttestation(args) {
2302
2535
  return 0;
2303
2536
  }
2304
2537
 
2538
+ // Finding Provenance PRD M4 §4.1: a provenance evidence bundle
2539
+ // (schema: agentic-security/provenance-evidence@1) is a fourth distinct
2540
+ // shape this same command can be handed — same auto-detection chain as
2541
+ // the ComplianceEvidence branch above, dispatched by schema marker
2542
+ // rather than a new CLI verb. Must be checked BEFORE the fallback
2543
+ // verifyEvidenceBundle() call below, which assumes evidence-bundle.js's
2544
+ // own shape (`.evidence`, `.finding` with severity/vuln/file/line) and
2545
+ // would misinterpret a provenance bundle.
2546
+ const { verifyProvenanceEvidenceBundle, PROVENANCE_BUNDLE_SCHEMA } = await import('../src/posture/provenance-evidence-bundle.js');
2547
+ if (bundle.schema === PROVENANCE_BUNDLE_SCHEMA) {
2548
+ const pr = verifyProvenanceEvidenceBundle(bundle, publicKeyPem);
2549
+ if (!pr.ok) { console.error(`✗ INVALID — ${pr.reason}`); return 1; }
2550
+ const p = bundle.provenance || {};
2551
+ console.log('✓ VALID — the provenance record is exactly what the signer attested.');
2552
+ console.log('');
2553
+ console.log(` finding: ${bundle.finding?.stableId || bundle.finding?.id || '?'}`);
2554
+ console.log(` status: ${p.status || 'n/a'} method: ${p.method || 'n/a'}`);
2555
+ // FR-PROV-026: findingOrigin.authorName is untrusted git commit metadata
2556
+ // (this bundle's signature only proves the BUNDLE wasn't tampered with —
2557
+ // it says nothing about what the original commit author put in their
2558
+ // name — see the "Signed, portable evidence" section of the root
2559
+ // CLAUDE.md), printed straight to the terminal by `verify-attestation`.
2560
+ if (p.findingOrigin) console.log(` origin: ${p.findingOrigin.commit || '?'} by ${sanitizeForTerminal(p.findingOrigin.authorName) || '?'} on ${p.findingOrigin.authorDate || '?'}`);
2561
+ console.log(` confidence: ${p.confidence?.level || 'n/a'} (${p.confidence?.score ?? 'n/a'})`);
2562
+ if ((p.limitations || []).length) console.log(` limitations: ${p.limitations.join('; ')}`);
2563
+ console.log('');
2564
+ console.log(` proves: ${bundle.proves}`);
2565
+ console.log(` does NOT prove: ${bundle.doesNotProve}`);
2566
+ return 0;
2567
+ }
2568
+
2305
2569
  const r = verifyEvidenceBundle(bundle, publicKeyPem);
2306
2570
  if (!r.ok) {
2307
2571
  console.error(`✗ INVALID — ${r.reason}`);
@@ -2947,4 +3211,31 @@ async function main() {
2947
3211
  }
2948
3212
  }
2949
3213
 
2950
- main();
3214
+ // Guard against running as the CLI entry point vs. being `import`ed (e.g. by
3215
+ // scanner/test/cli/provenance-flags.test.js, which imports parseProvenanceFlags
3216
+ // for a unit test). Without this, any import of this module — for a single
3217
+ // named export — re-runs the entire CLI dispatch and calls process.exit(),
3218
+ // killing whatever process did the importing.
3219
+ //
3220
+ // `import.meta.url === file://${process.argv[1]}` looks equivalent but is
3221
+ // NOT: when this script is invoked through a symlink (exactly what
3222
+ // `npm install -g`, `npx`, and `node_modules/.bin/<name>` all do for a
3223
+ // package's `bin` entries — which is the documented `npx
3224
+ // @clear-capabilities/agentic-security-scanner` install path), Node
3225
+ // resolves `import.meta.url` to the symlink's realpath while
3226
+ // `process.argv[1]` stays the symlink path as invoked, so the two never
3227
+ // match, the guard is always false, and the CLI silently exits with no
3228
+ // output. `import.meta.main` is resolved correctly through a symlink —
3229
+ // verified live through an actual symlink, not just read about — see the
3230
+ // Task 17 fix report. It was added in Node v24.2.0 (backported to
3231
+ // v22.18.0) and is currently Stability 1.0 (early development) per Node's
3232
+ // own docs — NOT stable, and NOT available on v20.11 as an earlier
3233
+ // version of this comment incorrectly claimed. Concretely: it is
3234
+ // `undefined` on Node 24.0.0/24.1.x, which satisfy this repo's declared
3235
+ // `engines.node: ">=24.0.0"` floor, so `import.meta.main` alone would
3236
+ // reproduce this exact bug (main() silently never runs) on a plain
3237
+ // non-symlinked invocation under those two point releases. The `??`
3238
+ // fallback below covers that gap without bumping the engines floor.
3239
+ if (import.meta.main ?? (import.meta.url === `file://${process.argv[1]}`)) {
3240
+ main();
3241
+ }
package/dist/113.index.js CHANGED
@@ -492,8 +492,8 @@ var external_node_child_process_ = __webpack_require__(1421);
492
492
  var external_node_fs_ = __webpack_require__(3024);
493
493
  // EXTERNAL MODULE: external "node:path"
494
494
  var external_node_path_ = __webpack_require__(6760);
495
- // EXTERNAL MODULE: ./src/engine.js + 211 modules
496
- var engine = __webpack_require__(7087);
495
+ // EXTERNAL MODULE: ./src/engine.js + 229 modules
496
+ var engine = __webpack_require__(7691);
497
497
  ;// CONCATENATED MODULE: ./src/posture/fix-honesty-gate.js
498
498
  // Deterministic honesty gates on fix / finding output (#7).
499
499
  //
@@ -904,7 +904,15 @@ async function verifyPatch({
904
904
  const fileContents = { ...files };
905
905
  let scan;
906
906
  try {
907
- scan = await (0,engine/* runFullScan */.wW)({ fileContents, depFileContents, scanRoot }, () => {});
907
+ // `provenance:false` is REQUIRED here, not an optimisation. This scan is
908
+ // deliberately scoped to just the patched file(s), so its finding set is a
909
+ // tiny subset of the project's. updateLifecycle marks every open stableId
910
+ // NOT in the set it is handed as `remediated` — so a single fix
911
+ // verification (every /fix, apply_fix, and autopilot iteration runs one)
912
+ // would mass-mark the rest of the project as remediated, then
913
+ // `reintroduced` on the next real scan. The patched content is also not
914
+ // committed, so there is no history to resolve provenance against anyway.
915
+ scan = await (0,engine/* runFullScan */.wW)({ fileContents, depFileContents, scanRoot, provenance: false }, () => {});
908
916
  } catch (e) {
909
917
  return { ok: false, reason: 'rescan-failed', error: e.message };
910
918
  }