@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
@@ -0,0 +1,144 @@
1
+ // Signed provenance evidence bundles (Finding Provenance PRD, M4 §4.1).
2
+ //
3
+ // Sibling to posture/evidence-bundle.js, not a generalization of it — that
4
+ // module's bundle shape (proofTier, taintPath, exploitability...) answers
5
+ // "is this finding real"; this one answers "who/when introduced it, how
6
+ // sure are we." Forcing one shape to cover both would leave half of every
7
+ // bundle null. Same four-function pattern (build/sign/verify + a schema
8
+ // string + a top-level-key allowlist), same Ed25519 key material — reused,
9
+ // not reimplemented.
10
+ //
11
+ // The allowlisted fields mirror provenance/coordinator.js's own
12
+ // computeDigest() material EXACTLY (stableId, findingOrigin.commit,
13
+ // branchIntroduction.commit, evidenceAttribution role:path:line:commit
14
+ // strings, method, confidence.reasons, limitations) plus repo/HEAD identity
15
+ // (not in computeDigest's material, since that digest never leaves the repo
16
+ // it was computed in, but a bundle does). Everything here is copied from
17
+ // what findingProvenance already computed. Nothing is inferred.
18
+ //
19
+ // canonicalJson vs canonicalBytes — resolved before writing this file
20
+ // -------------------------------------------------------------------
21
+ // evidence-bundle.js exports TWO canonicalisation helpers, not one, and they
22
+ // are not interchangeable. `canonicalJson(value)` is a pure, shape-agnostic
23
+ // deterministic serialiser (sorted keys at every level, order-preserving
24
+ // arrays) — safe to reuse for any bundle shape. `canonicalBytes(bundle)`
25
+ // is NOT generic: it hardcodes evidence-bundle.js's own six top-level keys
26
+ // (`schema, finding, evidence, engine, proves, doesNotProve`) when building
27
+ // the object it signs. Reusing `canonicalBytes` here would silently sign a
28
+ // filtered object missing this module's `repo` and `provenance` fields
29
+ // (and would read `bundle.evidence`, which a provenance bundle never has) —
30
+ // exactly the EA-03 failure mode the allowlist check below exists to catch,
31
+ // just introduced from the signing side instead of the verifying side. So
32
+ // this module imports the generic `canonicalJson` (as the brief's Step 1
33
+ // sketch already did) and signs `canonicalJson(bundle)` directly, since
34
+ // `buildProvenanceEvidenceBundle` never puts anything on the unsigned
35
+ // object beyond this module's own allowlisted fields.
36
+
37
+ import * as crypto from 'node:crypto';
38
+ import { ensureKeyPair, keyPaths, canonicalJson } from './evidence-bundle.js';
39
+
40
+ export const PROVENANCE_BUNDLE_SCHEMA = 'agentic-security/provenance-evidence@1';
41
+
42
+ const PROVES = 'This bundle\'s contents are exactly what was signed at attestation time.';
43
+ const DOES_NOT_PROVE = 'This bundle does NOT prove the origin commit is correctly identified — read confidence.level and limitations for that. It proves the RECORD is unmodified, not that the record is right.';
44
+
45
+ /**
46
+ * Build an unsigned provenance bundle from one finding's findingProvenance.
47
+ * Returns null for a finding with no findingProvenance at all (nothing to
48
+ * attest) — this is a caller error (attest a scan before its provenance
49
+ * pass ran), not a case to paper over with an empty bundle.
50
+ */
51
+ export function buildProvenanceEvidenceBundle(finding, { engineVersion, repoIdentity, head } = {}) {
52
+ if (!finding || typeof finding !== 'object') return null;
53
+ const fp = finding.findingProvenance;
54
+ if (!fp || typeof fp !== 'object') return null;
55
+ return {
56
+ schema: PROVENANCE_BUNDLE_SCHEMA,
57
+ finding: {
58
+ id: finding.id ?? null,
59
+ stableId: finding.stableId ?? null,
60
+ },
61
+ repo: {
62
+ identity: repoIdentity ?? null,
63
+ head: head ?? fp.analysisBasis?.head ?? null,
64
+ },
65
+ provenance: {
66
+ status: fp.status ?? null,
67
+ findingOrigin: fp.findingOrigin
68
+ ? {
69
+ commit: fp.findingOrigin.commit ?? null,
70
+ authorName: fp.findingOrigin.authorName ?? null,
71
+ authorDate: fp.findingOrigin.authorDate ?? null,
72
+ summary: fp.findingOrigin.summary ?? null,
73
+ }
74
+ : null,
75
+ branchIntroduction: fp.branchIntroduction
76
+ ? { commit: fp.branchIntroduction.commit ?? null, branch: fp.branchIntroduction.branch ?? null }
77
+ : null,
78
+ evidenceAttribution: (fp.evidenceAttribution || []).map((n) => ({
79
+ role: n.role ?? null, path: n.path ?? null, line: n.line ?? null, commit: n.commit ?? null,
80
+ })),
81
+ method: fp.method ?? null,
82
+ confidence: fp.confidence
83
+ ? { level: fp.confidence.level ?? null, score: fp.confidence.score ?? null, reasons: fp.confidence.reasons || [] }
84
+ : null,
85
+ limitations: fp.limitations || [],
86
+ // M4 §4.2 final-review fix: this module was written (Task 1) before
87
+ // the cross-repo lineage feature existed (Task 5), so a
88
+ // cross-repo-resolved origin's boundary-crossing marker was dropped
89
+ // from the signed bundle — a foreign repository's commit SHA and a
90
+ // real author name, with no MACHINE-READABLE signal that the origin
91
+ // crossed a repository boundary (only the prose in `limitations`
92
+ // said so, and a programmatic verifier does not read prose). Nested
93
+ // under `provenance` alongside the fields above, so it is part of
94
+ // what gets signed — never a new top-level key.
95
+ historyCoverage: { crossRepoLineage: fp.historyCoverage?.crossRepoLineage ?? false },
96
+ },
97
+ engine: { engineVersion: engineVersion ?? null },
98
+ proves: PROVES,
99
+ doesNotProve: DOES_NOT_PROVE,
100
+ };
101
+ }
102
+
103
+ export function signProvenanceEvidenceBundle(bundle, privateKeyPem) {
104
+ const sig = crypto.sign(null, Buffer.from(canonicalJson(bundle), 'utf8'), privateKeyPem);
105
+ return {
106
+ ...bundle,
107
+ signature: { algorithm: 'ed25519', canonicalisation: PROVENANCE_BUNDLE_SCHEMA, value: sig.toString('base64') },
108
+ };
109
+ }
110
+
111
+ const PROVENANCE_BUNDLE_TOP_LEVEL_KEYS = new Set([
112
+ 'schema', 'finding', 'repo', 'provenance', 'engine', 'proves', 'doesNotProve', 'signature',
113
+ ]);
114
+
115
+ /**
116
+ * Verify with a PUBLIC key only. Rejects any top-level key outside the
117
+ * allowlist BEFORE checking the signature — same EA-03 fix evidence-bundle.js
118
+ * carries: a signature only covers the bytes it was computed over, so an
119
+ * unknown key stapled on after signing would otherwise verify as authentic.
120
+ */
121
+ export function verifyProvenanceEvidenceBundle(bundle, publicKeyPem) {
122
+ if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
123
+ if (bundle.schema !== PROVENANCE_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
124
+ const unknownKeys = Object.keys(bundle).filter((k) => !PROVENANCE_BUNDLE_TOP_LEVEL_KEYS.has(k));
125
+ if (unknownKeys.length) {
126
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
127
+ }
128
+ const sig = bundle.signature;
129
+ if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
130
+ if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
131
+ if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
132
+ const { signature, ...unsigned } = bundle;
133
+ let ok = false;
134
+ try {
135
+ ok = crypto.verify(null, Buffer.from(canonicalJson(unsigned), 'utf8'), publicKeyPem, Buffer.from(sig.value, 'base64'));
136
+ } catch (e) {
137
+ return { ok: false, reason: `verification error: ${e.message}` };
138
+ }
139
+ return ok
140
+ ? { ok: true, reason: null }
141
+ : { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
142
+ }
143
+
144
+ export { ensureKeyPair, keyPaths };
@@ -24,7 +24,8 @@ import * as fs from 'node:fs';
24
24
  import { statePath, stateWritesEnabled } from './state-dir.js';
25
25
  import * as path from 'node:path';
26
26
  import * as crypto from 'node:crypto';
27
- import { execSync } from 'node:child_process';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
28
29
 
29
30
  const HISTORY_DIR = 'sbom-history';
30
31
 
@@ -32,9 +33,21 @@ function _historyDir(scanRoot) {
32
33
  return statePath(scanRoot, HISTORY_DIR);
33
34
  }
34
35
 
36
+ // `scanRoot` is the scanned project's repository, not this project's own
37
+ // trusted checkout — hardened per FR-PROV-024 / the second Finding
38
+ // Provenance PRD audit sweep (found missing here by a follow-up review that
39
+ // grepped for `child_process` usage beyond just `execFileSync('git'` call
40
+ // sites). `rev-parse HEAD` was VERIFIED not to itself trigger
41
+ // `core.fsmonitor`/a hook (it touches neither the working tree nor the
42
+ // index), so this is not a second live RCE — but leaving
43
+ // `GIT_CONFIG_NOSYSTEM`/`GIT_TERMINAL_PROMPT`/`core.hooksPath` unset here
44
+ // still violated this module's own "every git call routes through
45
+ // hardenGitArgs/hardenGitEnv" contract, and `execSync` (a shell string) is
46
+ // gratuitous risk this call never needed — `rev-parse HEAD` has no
47
+ // caller-controlled input to interpolate at all.
35
48
  function _gitHead(scanRoot) {
36
49
  try {
37
- return execSync('git rev-parse HEAD', { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
50
+ return execFileSync('git', hardenGitArgs(['rev-parse', 'HEAD']), { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
38
51
  } catch { return null; }
39
52
  }
40
53
 
@@ -12,6 +12,7 @@
12
12
  // needs network and is deferred — see the rollup.
13
13
 
14
14
  import { execFileSync } from 'node:child_process';
15
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
15
16
 
16
17
  // Pull the post-image (added) lines out of a unified diff: lines starting with
17
18
  // a single '+' (not the '+++' file header). Returns reconstructed text.
@@ -62,8 +63,15 @@ export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs
62
63
  if (!scanRoot || typeof detectFn !== 'function') return [];
63
64
  let out;
64
65
  try {
65
- out = execFileSync('git', ['-C', scanRoot, 'log', '-p', '-n', String(maxCommits), '--no-color', '--no-merges', '--no-textconv'],
66
- { encoding: 'utf8', maxBuffer: 96 * 1024 * 1024, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'] });
66
+ // Second independent Finding Provenance PRD audit (FR-PROV-024): this
67
+ // scanRoot is a scanned repository, not this project's own trusted
68
+ // checkout. `--no-textconv` alone (the pre-existing hardening here) closes
69
+ // the .gitattributes textconv surface but NOT `core.fsmonitor` /
70
+ // `core.hooksPath` — this `log -p` call renders every historical commit's
71
+ // diff content, the same shape verified exploitable in
72
+ // provenance/git-evidence.js, so it gets the full hardening too.
73
+ out = execFileSync('git', hardenGitArgs(['-C', scanRoot, 'log', '-p', '-n', String(maxCommits), '--no-color', '--no-merges', '--no-textconv']),
74
+ { encoding: 'utf8', maxBuffer: 96 * 1024 * 1024, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() });
67
75
  } catch { return []; }
68
76
  const parts = out.split(/^commit ([0-9a-f]{7,40})/m); // [pre, sha, body, sha, body, ...]
69
77
  const findings = [];
@@ -130,6 +130,14 @@ export function isSafeStateDir(dir) {
130
130
  // per-caller discipline already failed once: its header records a user who
131
131
  // uninstalled the plugin after stray state directories broke their build.
132
132
  let _stateWritesEnabled = true;
133
+ // Category-scoped override (M2 §2.4 performance fix): when the blanket
134
+ // switch above is OFF, a category listed here still writes. Exists for
135
+ // lsp/server.js, which needs the provenance disk cache live on every
136
+ // keystroke-save while every OTHER state write (dpia.md, lifecycle.json,
137
+ // ...) stays suppressed — see the withStateWritesDisabled call site in
138
+ // lsp/server.js for why the blanket switch alone made every LSP scan pay
139
+ // the FULL uncached provenance-resolution cost on every single save.
140
+ let _enabledCategories = new Set();
133
141
 
134
142
  /** Turn all state writing off (or back on) for this process. */
135
143
  export function setStateWritesEnabled(enabled) {
@@ -148,6 +156,15 @@ export function stateWritesEnabled() {
148
156
  return _stateWritesEnabled;
149
157
  }
150
158
 
159
+ // Category-aware check used by ensureStateDir/safeWriteState below. A
160
+ // caller that never passes `category` behaves EXACTLY as before: it only
161
+ // ever consults the blanket switch.
162
+ function _categoryEnabled(category) {
163
+ if (process.env.AGENTIC_SECURITY_NO_STATE === '1') return false;
164
+ if (_stateWritesEnabled) return true;
165
+ return !!category && _enabledCategories.has(category);
166
+ }
167
+
151
168
  /**
152
169
  * Run `fn` with state writes forced off, restoring the PRIOR flag value
153
170
  * afterward — for a caller (assurance-hardening PRD FR-704) that must
@@ -157,31 +174,38 @@ export function stateWritesEnabled() {
157
174
  * the same process — exactly `apply_fix`'s failure mode this wrapper exists
158
175
  * to prevent, via `finally` rather than caller discipline.
159
176
  *
160
- * KNOWN LIMITATION: `_stateWritesEnabled` is process-global, not per-call.
161
- * Two overlapping calls to this function (or one overlapping a direct
162
- * setStateWritesEnabled() call) can race and leave the flag in the wrong
163
- * state for one of them once both finish. mcp/CLAUDE.md already documents
164
- * an accepted concurrency limitation of the same shape for fix-history.js
165
- * ("concurrent apply_fix calls can race... today benign... a future
166
- * stateful tool needs serialization") this is the same class of risk, not
167
- * a new one, and this wrapper is still a strict improvement over the
168
- * alternative it replaces (a caller that writes state UNCONDITIONALLY on
177
+ * `exceptCategories` lets a caller keep ONE narrow category of write alive
178
+ * while everything else stays suppressed see lsp/server.js. Restored via
179
+ * the same `finally` as the blanket flag, for the same reason.
180
+ *
181
+ * KNOWN LIMITATION: `_stateWritesEnabled` and `_enabledCategories` are both
182
+ * process-global, not per-call. Two overlapping calls to this function (or
183
+ * one overlapping a direct setStateWritesEnabled() call) can race and leave
184
+ * the flags in the wrong state for one of them once both finish. mcp/CLAUDE.md
185
+ * already documents an accepted concurrency limitation of the same shape for
186
+ * fix-history.js ("concurrent apply_fix calls can race... today benign... a
187
+ * future stateful tool needs serialization") — this is the same class of
188
+ * risk, not a new one, and this wrapper is still a strict improvement over
189
+ * the alternative it replaces (a caller that writes state UNCONDITIONALLY on
169
190
  * every call, with no opt-out at all).
170
191
  */
171
- export async function withStateWritesDisabled(fn) {
192
+ export async function withStateWritesDisabled(fn, { exceptCategories = [] } = {}) {
172
193
  const prior = _stateWritesEnabled;
194
+ const priorCategories = new Set(_enabledCategories);
173
195
  _stateWritesEnabled = false;
196
+ for (const c of exceptCategories) _enabledCategories.add(c);
174
197
  try {
175
198
  return await fn();
176
199
  } finally {
177
200
  _stateWritesEnabled = prior;
201
+ _enabledCategories = priorCategories;
178
202
  }
179
203
  }
180
204
 
181
205
  // Safe mkdir: only creates .agentic-security/ if the parent has a project marker.
182
206
  // Returns the dir on success, null if refused. Logs a warning when refused.
183
- export function ensureStateDir(scanRoot) {
184
- if (!stateWritesEnabled()) return null;
207
+ export function ensureStateDir(scanRoot, { category } = {}) {
208
+ if (!_categoryEnabled(category)) return null;
185
209
  const dir = stateDir(scanRoot);
186
210
  if (!isSafeStateDir(dir)) {
187
211
  if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
@@ -199,8 +223,8 @@ export function ensureStateDir(scanRoot) {
199
223
 
200
224
  // Safe write: only writes if isSafeStateDir(parent) returns true.
201
225
  // Returns true on success, false if refused or errored.
202
- export function safeWriteState(filePath, content) {
203
- if (!stateWritesEnabled()) return false;
226
+ export function safeWriteState(filePath, content, { category } = {}) {
227
+ if (!_categoryEnabled(category)) return false;
204
228
  const dir = path.dirname(filePath);
205
229
  if (!isSafeStateDir(dir)) {
206
230
  if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
@@ -34,6 +34,7 @@
34
34
  // timeout, degrading to an empty result in a non-repository.
35
35
 
36
36
  import { execFileSync } from 'node:child_process';
37
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
37
38
 
38
39
  const CVE_RE = /\b(?:CVE-\d{4}-\d{4,7}|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})\b/i;
39
40
 
@@ -87,14 +88,19 @@ export function classifyCommit(subject) {
87
88
  return { tier: 'mentioned', evidence: `mentions "${noun[0]}" with no fix verb` };
88
89
  }
89
90
 
91
+ // `scanRoot` is the scanned project's repository, not this project's own
92
+ // trusted checkout — hardened per FR-PROV-024 / the second Finding
93
+ // Provenance PRD audit (same exposure class as
94
+ // provenance/git-evidence.js's `_run`). No `--no-textconv` needed: this
95
+ // invocation has no `-p`/`-L`, so no diff/blob content is rendered.
90
96
  function _gitLog(scanRoot, { maxCommits, timeoutMs }) {
91
97
  // NUL-delimited records so subjects containing newlines cannot split a
92
98
  // record — a commit message is arbitrary user text and must not be able to
93
99
  // forge a record boundary.
94
- const out = execFileSync('git', [
100
+ const out = execFileSync('git', hardenGitArgs([
95
101
  '-C', scanRoot, 'log', '-n', String(maxCommits), '--no-merges', '--no-color',
96
102
  '--name-only', '--format=%x00%H%x1f%aI%x1f%s',
97
- ], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
103
+ ]), { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() });
98
104
  const commits = [];
99
105
  for (const block of out.split('\0')) {
100
106
  if (!block.trim()) continue;
package/src/pr-delta.js CHANGED
@@ -24,18 +24,27 @@
24
24
  // comment) can transform without re-walking IR.
25
25
 
26
26
  import { spawnSync } from 'node:child_process';
27
+ import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
27
28
  import { runFullScan } from './engine.js';
28
29
 
29
30
  const FILE_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|java|cs|kt|go|rb|php|sol|swift|rs|tf|yml|yaml|json|toml|md)$/i;
30
31
  const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
31
32
 
33
+ // `root` is the PR's repository — a scan target, not this project's own
34
+ // trusted checkout — hardened per FR-PROV-024 / the second Finding
35
+ // Provenance PRD audit (same exposure class as
36
+ // provenance/git-evidence.js's `_run`).
32
37
  function _git(root, args) {
33
- const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
38
+ const r = spawnSync('git', hardenGitArgs(args), { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: hardenGitEnv() });
34
39
  return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '' };
35
40
  }
36
41
 
37
42
  function _readFileAtRef(root, ref, file) {
38
- const r = _git(root, ['show', `${ref}:${file}`]);
43
+ // `--no-textconv`: this blob-cat form of `show` (`<ref>:<file>`, not a
44
+ // diff) was verified NOT reachable via a hostile textconv driver in
45
+ // current git, same as git-evidence.js's getBlobAtCommit — kept for
46
+ // defense-in-depth/uniformity.
47
+ const r = _git(root, ['show', '--no-textconv', `${ref}:${file}`]);
39
48
  return r.ok ? r.stdout : null;
40
49
  }
41
50
 
@@ -56,7 +65,12 @@ async function _scanAtRef(root, ref) {
56
65
  const c = _readFileAtRef(root, ref, f);
57
66
  if (c != null) fileContents[f] = c;
58
67
  }
59
- return runFullScan({ fileContents, scanRoot: root }, () => {});
68
+ // `provenance:false` a base-ref snapshot, not the current working state.
69
+ // Beyond the wasted git walks, updateLifecycle marks every open stableId
70
+ // absent from the finding set it is handed as `remediated`; running the PR
71
+ // delta gate would silently rewrite the project's lifecycle store from the
72
+ // base ref's findings.
73
+ return runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
60
74
  }
61
75
 
62
76
  function _summary(findings) {
@@ -71,7 +85,14 @@ function _summary(findings) {
71
85
  }
72
86
 
73
87
  function _changedFiles(root, baseRef, headRef) {
74
- const r = _git(root, ['diff', '--name-only', `${baseRef}...${headRef}`]);
88
+ // `--no-ext-diff`: found during the second-audit-remediation sweep — this
89
+ // is a `git diff` call site (`--name-only`, no content rendered, so not
90
+ // itself a verified exploit path today, same as runScan.js's
91
+ // `changedSince`), but every `diff` invocation in this codebase gets it
92
+ // uniformly per the live exploit VERIFIED in material-change.js's
93
+ // classifyGitDiff (external diff drivers fire on `git diff` even with
94
+ // `--no-textconv`, which is a surface `--no-textconv` alone does not close).
95
+ const r = _git(root, ['diff', '--name-only', '--no-ext-diff', `${baseRef}...${headRef}`]);
75
96
  if (!r.ok) return new Set();
76
97
  return new Set(r.stdout.trim().split('\n').filter(Boolean));
77
98
  }