@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
@@ -125,6 +125,28 @@ export const ARTIFACT_REGISTRY = [
125
125
  { name: 'sca-upgrade-history', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
126
126
  { name: 'scan-baselines', kind: 'dir', classification: 'generated', retentionClass: 'scan', source: 'posture/pr-augment.js' },
127
127
  { name: 'agent-scratchpad', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'mcp/tools.js (append_scratchpad)' },
128
+ // Finding Provenance (M0/M1, split per PRD Section 8 retention task). Used
129
+ // to be one directory with two writers sharing it, which meant they could
130
+ // not get different retention treatment — `cmdReset`/`findExpiredArtifacts`
131
+ // only ever operate on exact TOP-LEVEL `.agentic-security/` directory
132
+ // names, never on a `/`-qualified sub-path. Now physically split:
133
+ // - posture/provenance/cache.js writes provenance-cache/<hash>.json — a
134
+ // pure HEAD-keyed memo of resolved origins, safely regenerable, no
135
+ // correctness dependency on being preserved. Gets retentionClass:
136
+ // 'cache' (7-day default / 30-day max TTL, RETENTION_DEFAULTS.cache).
137
+ // - posture/provenance/lifecycle.js writes provenance/lifecycle.json +
138
+ // .lock — the introduce/remediate/reintroduce ledger. Deliberately NO
139
+ // retentionClass: this is permanent history, not a cache; auto-expiring
140
+ // it would silently lose lifecycle events a report may already have
141
+ // cited. `reset` (without `--expired`) still clears it, which is
142
+ // explicit operator action, unlike TTL-driven auto-expiry.
143
+ // Old-location cache files (`provenance/cache/*.json`, written before this
144
+ // split) are DELIBERATELY NOT migrated — see cache.js's own comment and the
145
+ // commit that introduced this split. They are simply orphaned: invisible to
146
+ // this registry, un-swept by reset/retention, silently ignored by the new
147
+ // code, and harmless to leave until a human deletes them by hand.
148
+ { name: 'provenance-cache', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'posture/provenance/cache.js -- pure HEAD-keyed memo, safely regenerable, no correctness dependency on being preserved' },
149
+ { name: 'provenance', kind: 'dir', classification: 'generated', source: 'posture/provenance/lifecycle.js -- the introduce/remediate/reintroduce ledger. Deliberately NO retentionClass: this is permanent history, not a cache; auto-expiring it would silently lose lifecycle events a report may already have cited.' },
128
150
  { name: 'AGENTS.md', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
129
151
  { name: 'AGENTS.md.archive', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
130
152
  { name: 'baseline.json', kind: 'file', classification: 'generated', source: 'bin/agentic-security.js (--set-baseline)', note: 'operator-set intent, functionally closer to operator-config than scan output — no auto-expiry' },
@@ -171,6 +193,8 @@ export const ARTIFACT_REGISTRY = [
171
193
  { name: 'legal-holds.json', kind: 'file', classification: 'operator-config', note: 'FR-707 legal holds ({artifact, owner, reason, expires_at}) — read by posture/retention-policy.js and bin/agentic-security.js cmdReset; WRITTEN by the CLI (legal-hold add/remove), but classified operator-config (not generated) deliberately: a plain `reset` must never be able to delete the very record protecting other artifacts from deletion' },
172
194
  { name: 'calibration-feedback.jsonl', kind: 'file', classification: 'operator-config', note: 'FR-806 opt-in calibration ground truth ({at, findingId, outcome: accept-risk|realized-incident, predicted*, note}) — WRITTEN by the CLI (calibration-feedback record), but classified operator-config like exploit-history.jsonl: real, hard-to-recreate customer-reported ground truth, never scanner-regenerable, so a routine reset must never delete it' },
173
195
  { name: 'encryption-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-705 encryption provider/required opt-in policy ({provider: local-key, required: true|false}) — read by posture/encryption-provider.js, never written by the scanner' },
196
+ { name: 'provenance-providers.yml', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M3 §3.4 GitHub/GitLab provider enrichment opt-in ({token} or provider-scoped tokens) — read by posture/provenance/providers/config.js, never written by the scanner; env vars (AGENTIC_SECURITY_GITHUB_TOKEN/AGENTIC_SECURITY_GITLAB_TOKEN) take precedence when set' },
197
+ { name: 'repo-lineage.json', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M4 §4.2 cross-repository lineage declaration ({linkedFrom: {path, atCommit}}) — read by posture/provenance/repo-lineage.js, never written by the scanner; the linked path is verified as a real local git repo before use, no remote fetch' },
174
198
  { name: 'logic-claims.json', kind: 'file', classification: 'operator-config', note: 'authored by an external reviewing agent; engine.js only ever reads it (fs.readFileSync, never written)' },
175
199
  { name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
176
200
  { name: 'exploit-history.jsonl', kind: 'file', classification: 'operator-config', note: 'own header comment: "operator-curated record of past confirmed exploits"' },
@@ -41,9 +41,81 @@ import { statePath, stateWritesEnabled } from './state-dir.js';
41
41
  import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
42
42
  import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
43
43
  import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
44
+ // FR-PROV-026: earliestOrigin.authorName below is untrusted git commit
45
+ // metadata. renderWalkthrough()'s output is console.log'd verbatim by
46
+ // bin/agentic-security.js's `compliance --walkthrough` — the ONLY live
47
+ // consumer today (persistWalkthrough below is exported and tested but has
48
+ // zero callers anywhere in the CLI/command surface; no code in this repo
49
+ // ever runs this text through a real Markdown renderer). sanitizeForTerminal
50
+ // is therefore the correct sanitizer here, not a Markdown-escaping one — a
51
+ // backslash-escaping sibling was tried and reverted (see schema.js's header
52
+ // comment): printed raw or read as plain text, `Jean-Luc Picard` rendering
53
+ // as `Jean\-Luc Picard` and `dependabot[bot]` as `dependabot\[bot\]` is a
54
+ // visible regression on common real-world author names, not a fix.
55
+ import { sanitizeForTerminal, pseudonymizeAuthor, PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
56
+
57
+ // Fix-round item 4b: this renderer had `sanitizeForTerminal` (injection
58
+ // safety) but never honoured `--pseudonymize-authors` at all — an operator
59
+ // who set that policy still saw a raw committer name in walkthrough output,
60
+ // the one boundary that gap missed. Reads the SAME env var
61
+ // `report/index.js`'s `_normalizedProvenance` and `mcp/tools.js`'s
62
+ // `providerEnrichment`-aware call read back
63
+ // (AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 / --pseudonymize-authors). Keyed
64
+ // on name only, not email: `deriveComplianceProvenance`'s `earliestOrigin`
65
+ // deliberately never carries `authorEmail` (see its own comment — that
66
+ // object bypasses the `redactFindingProvenance` sweep entirely), so the
67
+ // pseudonym here is stable across repeated runs for the same author name but
68
+ // not necessarily identical to the email-keyed pseudonym shown at other
69
+ // output boundaries for the same person.
70
+ function _maybePseudonymizeName(name) {
71
+ if (!name) return name;
72
+ return process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1' ? pseudonymizeAuthor(name, null) : name;
73
+ }
44
74
 
45
75
  // Re-exported so existing callers/tests keep importing these from here.
46
76
  export { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys };
77
+
78
+ // FR-PROV-016 (M2): "earliest proven open condition" among a control's
79
+ // contributing findings. Prefers a finding whose findingProvenance resolved
80
+ // findingOrigin.status:'complete' (the OLDEST such authorDate wins); falls
81
+ // back to 'partial' entries with a resolved findingOrigin.authorDate when no
82
+ // complete one exists. Never fabricates an origin — zero usable entries is
83
+ // reported as null/'unknown', not the repo's first commit or "now".
84
+ export function deriveComplianceProvenance(findings) {
85
+ const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
86
+ const withOrigin = list
87
+ .map((f) => ({ f, fp: f && f.findingProvenance }))
88
+ .filter((x) => x.fp && x.fp.findingOrigin && x.fp.findingOrigin.authorDate);
89
+ const complete = withOrigin.filter((x) => x.fp.status === 'complete');
90
+ const partial = withOrigin.filter((x) => x.fp.status === 'partial');
91
+ // authorDate is git's `%aI` (strict ISO-8601, author's LOCAL UTC offset —
92
+ // see git-evidence.js's commitMeta), never normalized to Z. Two commits
93
+ // authored in different timezones near a day boundary can lexically sort
94
+ // in the wrong chronological order, so compare actual instants via
95
+ // Date.parse, never the raw strings.
96
+ const pickEarliest = (arr) => arr.reduce(
97
+ (min, x) => (!min || Date.parse(x.fp.findingOrigin.authorDate) < Date.parse(min.fp.findingOrigin.authorDate)) ? x : min,
98
+ null,
99
+ );
100
+ const best = complete.length ? pickEarliest(complete) : (partial.length ? pickEarliest(partial) : null);
101
+ return {
102
+ derivedFrom: [...new Set(list.map((f) => f && f.id).filter(Boolean))],
103
+ // Only commit/authorDate/authorName are ever read from findingOrigin
104
+ // here — this object is a SIBLING field to findingProvenance (not
105
+ // nested inside it), so it bypasses the redactFindingProvenance sweep
106
+ // that runs at report/mcp output boundaries. authorEmail must never be
107
+ // added to this shape without first routing it through
108
+ // redactFindingProvenance.
109
+ earliestOrigin: best ? {
110
+ commit: best.fp.findingOrigin.commit || null,
111
+ authorDate: best.fp.findingOrigin.authorDate,
112
+ authorName: best.fp.findingOrigin.authorName || null,
113
+ } : null,
114
+ confidence: complete.length ? 'high' : (partial.length ? 'low' : 'unknown'),
115
+ limitations: best ? [] : ['no contributing finding resolved a verified origin'],
116
+ };
117
+ }
118
+
47
119
  const BUNDLED_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'compliance-frameworks');
48
120
  function _readJson(fp) {
49
121
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
@@ -275,24 +347,30 @@ export function evaluateFramework(scanRoot, fw, scan) {
275
347
  const results = [];
276
348
  for (const c of fw.controls || []) {
277
349
  const obs = [];
350
+ // FR-PROV-016: findings that contributed an OPEN condition to this
351
+ // control's `family:` mapping(s) — the exact objects `open` below
352
+ // filters to, not just their ids, so deriveComplianceProvenance can read
353
+ // .findingProvenance off them. Naturally empty for a control that ends
354
+ // up 'present' (present requires zero open findings across every
355
+ // mapping) or 'manual' (no family: mapping ever populates it) — so a
356
+ // consumer can treat a non-empty controlRefs as "this control has an
357
+ // attributable gap" without re-deriving the bucket classification.
358
+ const contributingFindings = [];
278
359
  let status = 'manual';
279
360
  const maps = Array.isArray(c.mapsTo) ? c.mapsTo : [];
280
361
 
281
362
  if (maps.length === 0) {
282
363
  obs.push('No automated mapping — requires manual evidence collection.');
283
- // PRD F10.2: carry the MEASURED strength of the backing detector, so a
284
- // control mapped to a detector that finds 3 of 18 independent advisories
285
- // cannot read the same as one backed by a detector that finds nearly
286
- // everything. Import is lazy so the evaluator keeps working if the bench
287
- // artifacts are absent (they degrade to `unmeasured`, never to a default).
288
- let evidence = null;
289
- try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
290
- results.push({
291
- control: c,
292
- status,
293
- observations: obs,
294
- ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
295
- });
364
+ let evidence = null;
365
+ try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
366
+ results.push({
367
+ control: c,
368
+ status,
369
+ observations: obs,
370
+ controlRefs: [],
371
+ derivedProvenance: deriveComplianceProvenance([]),
372
+ ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
373
+ });
296
374
  continue;
297
375
  }
298
376
 
@@ -356,6 +434,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
356
434
  const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
357
435
  if (open.length) {
358
436
  allCleared = false;
437
+ contributingFindings.push(...open);
359
438
  obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
360
439
  } else {
361
440
  obs.push(`✓ ${fam}: no open ${minSeverity}+ findings.`);
@@ -461,10 +540,13 @@ export function evaluateFramework(scanRoot, fw, scan) {
461
540
  // artifacts are absent (they degrade to `unmeasured`, never to a default).
462
541
  let evidence = null;
463
542
  try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
543
+ const dedupedRefs = [...new Set(contributingFindings.map((f) => f.id).filter(Boolean))];
464
544
  results.push({
465
545
  control: c,
466
546
  status,
467
547
  observations: obs,
548
+ controlRefs: dedupedRefs,
549
+ derivedProvenance: deriveComplianceProvenance(contributingFindings),
468
550
  ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
469
551
  });
470
552
  }
@@ -519,6 +601,27 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
519
601
  if (ev.status === 'absent' || ev.status === 'partial') {
520
602
  lines.push(`**Remediation:** address the bullet(s) above, then re-run \`/compliance --walkthrough ${fw.id}\` to update this report.`);
521
603
  lines.push('');
604
+ if (Array.isArray(ev.controlRefs) && ev.controlRefs.length) {
605
+ lines.push(`**Contributing findings:** ${ev.controlRefs.join(', ')}`);
606
+ const dp = ev.derivedProvenance;
607
+ if (dp && dp.earliestOrigin) {
608
+ const short = String(dp.earliestOrigin.commit || '').slice(0, 7) || 'unknown';
609
+ const day = String(dp.earliestOrigin.authorDate || '').slice(0, 10);
610
+ // Only commit/authorDate/authorName are ever read here — same
611
+ // caveat as deriveComplianceProvenance's earliestOrigin: this
612
+ // object bypasses redactFindingProvenance, so authorEmail must
613
+ // never be surfaced from it without routing through that function
614
+ // first.
615
+ lines.push(`**Earliest proven origin:** ${short} — ${day} — ${sanitizeForTerminal(_maybePseudonymizeName(dp.earliestOrigin.authorName)) || 'unknown'} (confidence: ${dp.confidence})`);
616
+ // PRD Section 8 REQUIRED DISCLAIMER, alongside the claim it
617
+ // qualifies (not just once at the top of the document) — a reader
618
+ // who skips straight to a control's evidence must still see it.
619
+ lines.push(`_${PROVENANCE_COMPLIANCE_DISCLAIMER}_`);
620
+ } else if (dp) {
621
+ lines.push(`**Earliest proven origin:** unresolved (confidence: ${dp.confidence})`);
622
+ }
623
+ lines.push('');
624
+ }
522
625
  }
523
626
  }
524
627
 
@@ -64,7 +64,8 @@
64
64
  import * as fs from 'node:fs';
65
65
  import * as path from 'node:path';
66
66
  import * as crypto from 'node:crypto';
67
- import { execSync } from 'node:child_process';
67
+ import { execFileSync } from 'node:child_process';
68
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
68
69
  import * as yaml from '../util/yaml.js';
69
70
  import { statePath, safeWriteState, STATE_DIR_NAME } from './state-dir.js';
70
71
  import { SCANNER_VERSION } from './version.js';
@@ -367,10 +368,19 @@ export function verifyPolicy(policy, ctx) {
367
368
  return { framework: policy.framework, version: policy.version, controls: results, summary, evidenceDigest };
368
369
  }
369
370
 
371
+ // `scanRoot` is the scanned project's repository, not this project's own
372
+ // trusted checkout — hardened per FR-PROV-024 / the second Finding
373
+ // Provenance PRD audit sweep (found missing here by a follow-up review that
374
+ // grepped for `child_process` usage beyond just `execFileSync('git'` call
375
+ // sites). `rev-parse HEAD` was VERIFIED not to itself trigger
376
+ // `core.fsmonitor`/a hook, so this is not a second live RCE — but the
377
+ // shell-string `execSync` form was gratuitous risk with no upside (no
378
+ // caller-controlled input to interpolate), and left this call outside the
379
+ // config/env hardening every other git call in this codebase now has.
370
380
  function _currentCommit(scanRoot) {
371
381
  if (!scanRoot) return null;
372
382
  try {
373
- return execSync('git rev-parse HEAD', { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
383
+ return execFileSync('git', hardenGitArgs(['rev-parse', 'HEAD']), { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
374
384
  } catch { return null; } // not a git repo, or git unavailable — not an error condition
375
385
  }
376
386
 
@@ -24,6 +24,7 @@ import * as cp from 'node:child_process';
24
24
  import * as fs from 'node:fs';
25
25
  import * as crypto from 'node:crypto';
26
26
  import * as path from 'node:path';
27
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
27
28
 
28
29
  // Lazy — process.env.HOME may be mutated mid-process (e.g. tests isolating).
29
30
  function _storeDir() {
@@ -43,8 +44,12 @@ function _ensureDir() { try { fs.mkdirSync(_storeDir(), { recursive: true }); }
43
44
  export function repoFingerprint(scanRoot) {
44
45
  let source = String(scanRoot || '');
45
46
  try {
46
- const remote = cp.execFileSync('git', ['remote', 'get-url', 'origin'],
47
- { cwd: scanRoot, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
47
+ // `scanRoot` is the scanned project's repository, not this project's
48
+ // own trusted checkout hardened per FR-PROV-024 / the second Finding
49
+ // Provenance PRD audit (same exposure class as
50
+ // provenance/git-evidence.js's `_run`).
51
+ const remote = cp.execFileSync('git', hardenGitArgs(['remote', 'get-url', 'origin']),
52
+ { cwd: scanRoot, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
48
53
  if (remote) source = remote;
49
54
  } catch {}
50
55
  return crypto.createHash('sha256').update(source).digest('hex').slice(0, 12);
@@ -13,6 +13,7 @@ import * as fsp from 'node:fs/promises';
13
13
  import * as path from 'node:path';
14
14
  import * as crypto from 'node:crypto';
15
15
  import { isSafeStateDir, statePath, stateWritesEnabled } from './state-dir.js';
16
+ import { AGE_BASIS } from './provenance/schema.js';
16
17
 
17
18
  function historyDir(scanRoot) {
18
19
  return statePath(scanRoot, 'fix-history');
@@ -238,6 +239,26 @@ function _countPriorAttempts(log, stableId, findingId) {
238
239
  return n;
239
240
  }
240
241
 
242
+ // FR-PROV §7.4 / M2 §2.2: how old was this finding, by which basis, at the
243
+ // moment it was fixed. Computed ONCE, at fix time, and never re-derived
244
+ // later — a finding's origin doesn't change, but re-computing "age at fix"
245
+ // from a LATER read of findingProvenance would silently answer "how old is
246
+ // it now", not "how old was it when fixed". Mirrors mttr.js's ageBasis
247
+ // tiering (Task 6) so the two surfaces agree on vocabulary.
248
+ function _snapshotProvenanceAtFix(findingProvenance, appliedAt) {
249
+ if (!findingProvenance) return null;
250
+ const status = findingProvenance.status;
251
+ const origin = findingProvenance.findingOrigin;
252
+ const observedAt = findingProvenance.firstObserved?.observedAt || null;
253
+ let ageBasis, basisDate;
254
+ if (status === 'complete' && origin?.authorDate) { ageBasis = AGE_BASIS.FINDING_ORIGIN; basisDate = origin.authorDate; }
255
+ else if (status === 'partial' && origin?.authorDate) { ageBasis = AGE_BASIS.EARLIEST_OBSERVABLE; basisDate = origin.authorDate; }
256
+ else if (status === 'uncommitted') { ageBasis = AGE_BASIS.UNCOMMITTED; basisDate = observedAt; }
257
+ else { ageBasis = AGE_BASIS.FIRST_OBSERVED; basisDate = observedAt; }
258
+ const ageDays = basisDate ? Math.max(0, Math.floor((Date.parse(appliedAt) - Date.parse(basisDate)) / 86400000)) : null;
259
+ return { commit: origin?.commit || null, authorDate: basisDate, ageBasis, ageDays };
260
+ }
261
+
241
262
  // @param {boolean} [fileExisted] - did `file` exist on disk before this call?
242
263
  // Determines what "restore" means on rollback: write `originalContent`
243
264
  // back for a file that existed (default, for backward compatibility with
@@ -247,7 +268,7 @@ function _countPriorAttempts(log, stableId, findingId) {
247
268
  // real-world meaning in this codebase's callers — writing '' back would
248
269
  // leave a phantom empty file where none existed before, not a true
249
270
  // rollback).
250
- export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fileExisted = true }) {
271
+ export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fileExisted = true, findingProvenance = null }) {
251
272
  return _withLogLock(scanRoot, async () => {
252
273
  ensure(scanRoot);
253
274
  const absFile = path.resolve(scanRoot, file);
@@ -269,6 +290,7 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
269
290
  // is below — a corrupted backup is worse than no backup, because it
270
291
  // silently defeats rollback.
271
292
  await _writeAtomicAndSync(bakPath, originalContent);
293
+ const appliedAt = new Date().toISOString();
272
294
  const entry = {
273
295
  id,
274
296
  findingId,
@@ -280,10 +302,11 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
280
302
  backupPath: path.relative(scanRoot, bakPath),
281
303
  originalSha: sha(originalContent),
282
304
  newSha: sha(newContent),
283
- appliedAt: new Date().toISOString(),
305
+ appliedAt,
284
306
  status: 'pending',
285
307
  reverted: false,
286
308
  attemptOrdinal: priorAttempts + 1,
309
+ provenanceAtFix: _snapshotProvenanceAtFix(findingProvenance, appliedAt),
287
310
  };
288
311
  // Phase 2: log entry marked pending + fsync.
289
312
  const log = priorLog;
@@ -38,7 +38,15 @@ export async function verifyPatch({
38
38
  const fileContents = { ...files };
39
39
  let scan;
40
40
  try {
41
- scan = await runFullScan({ fileContents, depFileContents, scanRoot }, () => {});
41
+ // `provenance:false` is REQUIRED here, not an optimisation. This scan is
42
+ // deliberately scoped to just the patched file(s), so its finding set is a
43
+ // tiny subset of the project's. updateLifecycle marks every open stableId
44
+ // NOT in the set it is handed as `remediated` — so a single fix
45
+ // verification (every /fix, apply_fix, and autopilot iteration runs one)
46
+ // would mass-mark the rest of the project as remediated, then
47
+ // `reintroduced` on the next real scan. The patched content is also not
48
+ // committed, so there is no history to resolve provenance against anyway.
49
+ scan = await runFullScan({ fileContents, depFileContents, scanRoot, provenance: false }, () => {});
42
50
  } catch (e) {
43
51
  return { ok: false, reason: 'rescan-failed', error: e.message };
44
52
  }
Binary file
@@ -19,14 +19,19 @@
19
19
  import * as cp from 'node:child_process';
20
20
  import * as fs from 'node:fs';
21
21
  import * as path from 'node:path';
22
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
22
23
 
23
24
  const MAX_BLAME_PER_SCAN = 500;
24
25
  const SUBPROC_TIMEOUT_MS = 1500;
25
26
  const PROMPT_MARKER_RE = /(?:^|\n)(?:Prompt|User asked|Original request|Co-Authored-By:\s*Claude)/i;
26
27
 
28
+ // `scanRoot` is the scanned project's repository, not this project's own
29
+ // trusted checkout — every call below is hardened per FR-PROV-024 / the
30
+ // second Finding Provenance PRD audit (same exposure class as
31
+ // provenance/git-evidence.js's `_run`).
27
32
  function _isGitRepo(scanRoot) {
28
33
  try {
29
- cp.execFileSync('git', ['rev-parse', '--git-dir'], { cwd: scanRoot, stdio: 'ignore', timeout: SUBPROC_TIMEOUT_MS });
34
+ cp.execFileSync('git', hardenGitArgs(['rev-parse', '--git-dir']), { cwd: scanRoot, stdio: 'ignore', timeout: SUBPROC_TIMEOUT_MS, env: hardenGitEnv() });
30
35
  return true;
31
36
  } catch { return false; }
32
37
  }
@@ -36,10 +41,13 @@ function _blame(scanRoot, file, line) {
36
41
  const rel = path.isAbsolute(file) ? path.relative(scanRoot, file) : file;
37
42
  if (rel.startsWith('..')) return null;
38
43
  try {
44
+ // `--no-textconv`: VERIFIED exploitable without it — `git blame`
45
+ // applies a hostile `.gitattributes` textconv driver by default in
46
+ // current git, same as provenance/git-evidence.js's blameLine.
39
47
  const stdout = cp.execFileSync(
40
48
  'git',
41
- ['blame', '-L', `${line},${line}`, '--porcelain', '--', rel],
42
- { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
49
+ hardenGitArgs(['blame', '-L', `${line},${line}`, '--porcelain', '--no-textconv', '--', rel]),
50
+ { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() },
43
51
  );
44
52
  return _parsePorcelain(stdout);
45
53
  } catch { return null; }
@@ -66,8 +74,8 @@ function _parsePorcelain(out) {
66
74
  function _fullMessage(scanRoot, sha) {
67
75
  try {
68
76
  return cp.execFileSync(
69
- 'git', ['show', '-s', '--format=%B', sha],
70
- { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
77
+ 'git', hardenGitArgs(['show', '-s', '--no-textconv', '--format=%B', sha]),
78
+ { cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() },
71
79
  );
72
80
  } catch { return ''; }
73
81
  }
@@ -11,6 +11,7 @@
11
11
  // or the command runner) collects the unified diff and feeds hunks into classifyHunk.
12
12
 
13
13
  import * as cp from 'node:child_process';
14
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
14
15
  import { loadPrivacyTaxonomy } from '../dataflow/privacy-taxonomy.js';
15
16
 
16
17
  // Patterns that fire on the deletion side (auth/check removed).
@@ -196,11 +197,29 @@ function summarize(findings) {
196
197
  }
197
198
 
198
199
  // Convenience: invoke `git diff <ref>...HEAD` for the project and classify it.
200
+ //
201
+ // `rootDir` is the scanned project's repository, not this project's own
202
+ // trusted checkout. `--no-textconv` is load-bearing here, not
203
+ // defense-in-depth: this renders real diff content, the same shape VERIFIED
204
+ // exploitable via a hostile `.gitattributes` textconv driver in
205
+ // provenance/git-evidence.js's `commitDiff` (FR-PROV-024 / the second audit).
206
+ //
207
+ // `--no-ext-diff` is ALSO load-bearing and is a SEPARATE surface from
208
+ // `--no-textconv`: `git diff` (unlike `git show`/`git log -p`/`git blame`)
209
+ // honours an external diff driver (`.gitattributes` `diff=<name>` +
210
+ // `.git/config [diff "<name>"] command=<script>`, or the global
211
+ // `diff.external`) even with `--no-textconv` set — VERIFIED empirically: the
212
+ // exact argv this function shipped with before this fix
213
+ // (`-c core.fsmonitor= -c core.hooksPath=/dev/null diff --unified=0
214
+ // --no-textconv <ref>...HEAD`) still ran an attacker's `diff.evil.command`
215
+ // script. This was the live, remaining RCE a second review caught: this
216
+ // function is the real entry point for `/scan --diff` and
217
+ // `security-material-change`, both invoked against the scanned project.
199
218
  export function classifyGitDiff(rootDir, ref) {
200
219
  let out;
201
220
  try {
202
- out = cp.execFileSync('git', ['diff', '--unified=0', `${ref}...HEAD`], {
203
- cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
221
+ out = cp.execFileSync('git', hardenGitArgs(['diff', '--unified=0', '--no-textconv', '--no-ext-diff', `${ref}...HEAD`]), {
222
+ cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
204
223
  });
205
224
  } catch (e) {
206
225
  return { materialRisk: 'unknown', error: 'git diff failed: ' + (e.message || e), findings: [], perKindCounts: {}, byFile: {} };
@@ -8,6 +8,29 @@
8
8
  // when to persist firstSeenAt back into the baseline.
9
9
 
10
10
  import * as crypto from 'node:crypto';
11
+ import { AGE_BASIS } from './provenance/schema.js';
12
+
13
+ // FR-PROV-019: "reports never show an age without its basis and confidence."
14
+ // FINDING_ORIGIN is the only basis backed by a resolved git commit for the
15
+ // finding's actual introduction; EARLIEST_OBSERVABLE is also git-derived but
16
+ // partial (weaker claim, no exact introduction commit). UNCOMMITTED and
17
+ // FIRST_OBSERVED are both wall-clock fallbacks — the age is a first-seen
18
+ // timestamp, never resolved against git history — and must say so honestly
19
+ // rather than read like a proven date.
20
+ function _ageBasisLabel(ageBasis, confidence) {
21
+ const level = confidence?.level && confidence.level !== 'unknown' ? confidence.level.toUpperCase() : null;
22
+ switch (ageBasis) {
23
+ case AGE_BASIS.FINDING_ORIGIN:
24
+ return `proven origin${level ? `, ${level} confidence` : ''}`;
25
+ case AGE_BASIS.EARLIEST_OBSERVABLE:
26
+ return `earliest observable commit, partial history${level ? `, ${level} confidence` : ''}`;
27
+ case AGE_BASIS.UNCOMMITTED:
28
+ return 'uncommitted — first-seen fallback, origin not proven';
29
+ case AGE_BASIS.FIRST_OBSERVED:
30
+ default:
31
+ return 'first-seen fallback — origin not proven';
32
+ }
33
+ }
11
34
 
12
35
  // Stable fingerprint for cross-scan finding identity. Mirrors the dedupe key.
13
36
  // Exported so a caller can compute the "removed since baseline" (i.e. fixed)
@@ -37,6 +60,25 @@ export function stampFindingTimestamps(findings, baselineMap = new Map(), now =
37
60
  f.lastSeenAt = nowIso;
38
61
  const firstMs = Date.parse(f.firstSeenAt);
39
62
  f.ageDays = Math.max(0, Math.floor((now - firstMs) / 86400000));
63
+ // FR-PROV-019: age/SLA basis. ageDays above stays pure wall-clock —
64
+ // every existing SLA/computeMTTR consumer keeps its current meaning.
65
+ // ageBasis + provenAgeDays are ADDITIVE: a report can show both and
66
+ // explain the discrepancy, never silently swap which number "age" means.
67
+ const status = f.findingProvenance?.status;
68
+ const origin = f.findingProvenance?.findingOrigin;
69
+ if (status === 'complete' && origin?.authorDate) {
70
+ f.ageBasis = AGE_BASIS.FINDING_ORIGIN;
71
+ f.provenAgeDays = Math.max(0, Math.floor((now - Date.parse(origin.authorDate)) / 86400000));
72
+ } else if (status === 'partial' && origin?.authorDate) {
73
+ f.ageBasis = AGE_BASIS.EARLIEST_OBSERVABLE;
74
+ f.provenAgeDays = Math.max(0, Math.floor((now - Date.parse(origin.authorDate)) / 86400000));
75
+ } else if (status === 'uncommitted') {
76
+ f.ageBasis = AGE_BASIS.UNCOMMITTED;
77
+ f.provenAgeDays = f.ageDays;
78
+ } else {
79
+ f.ageBasis = AGE_BASIS.FIRST_OBSERVED;
80
+ f.provenAgeDays = f.ageDays;
81
+ }
40
82
  }
41
83
  return findings;
42
84
  }
@@ -68,28 +110,49 @@ export function findingsExceedingSLA(findings, slaDays = null) {
68
110
  });
69
111
  }
70
112
 
71
- // Median age (days) of the currently-open findings a single-scan proxy for
72
- // "how long has this debt been sitting". True MTTR (computeMTTR) needs the set
73
- // of findings that were FIXED; this reports the open backlog's median age so a
74
- // scan can show whether debt is getting older. Returns null on empty input.
75
- // Local surfaced only through renderSlaSummary (its sole consumer).
76
- function medianOpenAgeDays(findings) {
77
- const ages = (findings || []).map(f => f.ageDays || 0).sort((a, b) => a - b);
78
- if (!ages.length) return null;
79
- return ages[Math.floor(ages.length / 2)];
113
+ // Median age (days) of the currently-open findings, PLUS the ageBasis/
114
+ // confidence of whichever finding landed on that median a single-scan proxy
115
+ // for "how long has this debt been sitting". True MTTR (computeMTTR) needs the
116
+ // set of findings that were FIXED; this reports the open backlog's median age
117
+ // so a scan can show whether debt is getting older. Returns null on empty
118
+ // input. Local — surfaced only through renderSlaSummary (its sole consumer).
119
+ //
120
+ // FR-PROV-019: the days figure prefers `provenAgeDays` (git-derived when
121
+ // available) over the pure-wall-clock `ageDays`, and the ageBasis/confidence
122
+ // travel WITH the day count they describe — never a bare number with the
123
+ // basis looked up separately, which is how the original miss happened
124
+ // (ageBasis was stamped onto the finding but never reached the string that
125
+ // printed its age). Findings stamped by an older/test caller that never ran
126
+ // through stampFindingTimestamps (no ageBasis at all) degrade to the same
127
+ // honest "not proven" label FIRST_OBSERVED gets, never a false claim.
128
+ function medianOpenAge(findings) {
129
+ const entries = (findings || [])
130
+ .map(f => ({
131
+ days: f.provenAgeDays != null ? f.provenAgeDays : (f.ageDays || 0),
132
+ ageBasis: f.ageBasis || AGE_BASIS.FIRST_OBSERVED,
133
+ confidence: f.findingProvenance?.confidence || null,
134
+ }))
135
+ .sort((a, b) => a.days - b.days);
136
+ if (!entries.length) return null;
137
+ return entries[Math.floor(entries.length / 2)];
80
138
  }
81
139
 
82
140
  // One-line SLA-breach summary for surfacing after a scan (#10). Returns null
83
- // when nothing is past its per-severity SLA. Pairs with medianOpenAgeDays for a
141
+ // when nothing is past its per-severity SLA. Pairs with medianOpenAge for a
84
142
  // "is my security debt aging" readout that the vibecoder can act on.
143
+ //
144
+ // FR-PROV-019: never prints the median age number without its basis and
145
+ // confidence alongside it — see medianOpenAge/_ageBasisLabel above.
85
146
  export function renderSlaSummary(findings, slaDays = null) {
86
147
  const breached = findingsExceedingSLA(findings || [], slaDays);
87
148
  if (!breached.length) return null;
88
149
  const bySev = {};
89
150
  for (const f of breached) bySev[f.severity] = (bySev[f.severity] || 0) + 1;
90
151
  const parts = ['critical', 'high', 'medium', 'low', 'info'].filter(s => bySev[s]).map(s => `${bySev[s]} ${s}`);
91
- const median = medianOpenAgeDays(findings);
92
- const ageNote = median != null ? ` (median open age ${median}d)` : '';
152
+ const median = medianOpenAge(findings);
153
+ const ageNote = median != null
154
+ ? ` (median open age ${median.days}d, ${_ageBasisLabel(median.ageBasis, median.confidence)})`
155
+ : '';
93
156
  return `${breached.length} finding(s) past remediation SLA: ${parts.join(', ')}${ageNote}`;
94
157
  }
95
158