@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
@@ -5,6 +5,19 @@ import { alertFace, approveFace } from './mascot.js';
5
5
  import { SCANNER_VERSION } from '../posture/version.js';
6
6
  import { proofBlock } from '../posture/proof-artifact.js';
7
7
  import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/legacy-compat.js';
8
+ // Finding Provenance (M0/M1). NOTE the name: `findingProvenance` is the
9
+ // git-origin record from posture/provenance/. It is NOT `finding.provenance`
10
+ // (posture/ai-code-fingerprint.js's AI-authorship signal, normalized a few
11
+ // hundred lines below) and NOT `supplyChainEntry.provenance`
12
+ // (sca/sigstore-verify.js's SLSA/Sigstore build attestation). Three unrelated
13
+ // things, three distinct keys — do not collapse them.
14
+ import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
15
+ // Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
16
+ // posture/auditor-walkthrough.js, a second CLI renderer of the same
17
+ // untrusted fields — see that module's header for why it lives there
18
+ // rather than here); re-exported so `explainProvenance`'s own module keeps
19
+ // being the discoverable home for provenance-text callers.
20
+ export { sanitizeForTerminal };
8
21
 
9
22
  const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
10
23
  const SEV_TO_SARIF = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'none' };
@@ -84,6 +97,62 @@ function explainParts(f, { verbose = false } = {}) {
84
97
  return { why, how, fix: fix.replace(/\s+/g, ' ').trim(), fixCode };
85
98
  }
86
99
 
100
+ // Human-readable rendering of a finding's git-origin provenance (FR-PROV
101
+ // output surface). Returns null when the finding carries none — a caller
102
+ // prints the block or omits it, and never has to know the schema.
103
+ //
104
+ // Every one of the six TERMINAL statuses gets a line of its own, and the
105
+ // non-`complete` ones say WHY in the status word itself rather than rendering
106
+ // an empty origin: that is the whole reason the status enum exists (see
107
+ // posture/provenance/coordinator.js's header). `Method` and `Confidence` are
108
+ // unconditional — a block that omitted them for a degraded status would read
109
+ // as "origin unknown" when the honest statement is "resolved by <method> to
110
+ // <confidence>". Nothing here throws on a partially-populated object; every
111
+ // field access is optional-chained or defaulted, because a provenance record
112
+ // that survived a failure path is exactly the input this has to render.
113
+ export function explainProvenance(f) {
114
+ const fp = f && f.findingProvenance;
115
+ if (!fp) return null;
116
+ const short = (v) => String(v || '').slice(0, 7);
117
+ const day = (v) => String(v || '').slice(0, 10);
118
+ const lines = [];
119
+ const o = fp.findingOrigin;
120
+ if (fp.status === 'complete' && o) {
121
+ lines.push(`Introduced: ${short(o.commit)} • ${day(o.authorDate)} • ${sanitizeForTerminal(o.authorName) || 'unknown'}`);
122
+ const bi = fp.branchIntroduction;
123
+ if (bi && bi.commit !== o.commit) {
124
+ // relationship is an internal enum ('merge'/'direct', see
125
+ // branch-entry.js) never sourced from untrusted git text — wrapped
126
+ // anyway for defense in depth and consistency with authorName above.
127
+ lines.push(`Branch entry: ${short(bi.commit)} • ${sanitizeForTerminal(bi.relationship) || 'unknown relationship'}`);
128
+ }
129
+ } else if (fp.status === 'partial') {
130
+ lines.push(`Origin: EARLIEST OBSERVABLE${o ? ' ' + short(o.commit) : ''}`);
131
+ } else if (fp.status === 'uncommitted') {
132
+ lines.push('Origin: UNCOMMITTED (working tree only)');
133
+ } else if (fp.status === 'not_available') {
134
+ lines.push('Origin: NOT AVAILABLE');
135
+ } else if (fp.status === 'error') {
136
+ lines.push('Origin: ERROR resolving provenance');
137
+ } else if (fp.status === 'budget_exhausted') {
138
+ lines.push('Origin: BUDGET EXHAUSTED before resolution completed');
139
+ } else {
140
+ // An unrecognised status is reported as itself, not silently dropped — a
141
+ // future status added to the enum must be visible here on the day it
142
+ // ships, even before this renderer learns a nicer wording for it.
143
+ lines.push(`Origin: ${String(fp.status || 'unknown').toUpperCase()}`);
144
+ }
145
+ if (fp.firstObserved) {
146
+ lines.push(`First observed: ${fp.firstObserved.scanId || 'unknown scan'} • ${day(fp.firstObserved.observedAt)}`);
147
+ }
148
+ lines.push(`Method: ${fp.method || 'none'}`);
149
+ lines.push(`Confidence: ${String(fp.confidence?.level || 'unknown').toUpperCase()}`);
150
+ if (Array.isArray(fp.limitations) && fp.limitations.length) {
151
+ lines.push(`Limitations: ${fp.limitations.join('; ')}`);
152
+ }
153
+ return lines.join('\n');
154
+ }
155
+
87
156
  function fingerprint(f){
88
157
  const s = `${f.file}:${f.line||f.source?.line||0}:${f.vuln||f.type||''}`;
89
158
  return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
@@ -108,6 +177,24 @@ export function _remediationOf(f) {
108
177
  return null;
109
178
  }
110
179
 
180
+ // Redacted passthrough for the git-origin provenance record (FR-PROV output
181
+ // surface). Author EMAIL is PII and is withheld by default EVERYWHERE — raw
182
+ // JSON included, since `toJSON` derives from this same function and an email
183
+ // in a committed report artifact is a leak nobody opted into. Set
184
+ // AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL=1 to include it (read per call, not
185
+ // cached at module load, so a test or a wrapper can set it per invocation).
186
+ // `authorName` is deliberately NOT redacted by default: it is already the
187
+ // value the existing `introducedBy` field has carried for releases. Set
188
+ // AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 (--pseudonymize-authors) to replace
189
+ // it with a stable Contributor-XXXXXXXX id instead — PRD Section 8.
190
+ function _normalizedProvenance(f) {
191
+ if (!f || !f.findingProvenance) return null;
192
+ return redactFindingProvenance(f.findingProvenance, {
193
+ includeEmail: process.env.AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL === '1',
194
+ pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
195
+ });
196
+ }
197
+
111
198
  export function normalizeFindings(scan){
112
199
  const out = [];
113
200
  // Feat-4: filter findings via custom suppressions, recording the suppression
@@ -250,6 +337,10 @@ export function normalizeFindings(scan){
250
337
  cloneClusterSize: typeof f.cloneClusterSize === 'number' ? f.cloneClusterSize : null,
251
338
  provenance: f.provenance || null,
252
339
  provenanceScore: typeof f.provenanceScore === 'number' ? f.provenanceScore : null,
340
+ // posture/provenance/coordinator.js#annotateGitProvenance — WHICH COMMIT
341
+ // introduced this finding. A different question, and a different field,
342
+ // from the two lines above it (see the import comment at the top).
343
+ findingProvenance: _normalizedProvenance(f),
253
344
  typeNarrowed: f.typeNarrowed || null,
254
345
  strideCategory: f.strideCategory || null,
255
346
  personaScores: f.personaScores || null,
@@ -349,6 +440,15 @@ export function normalizeFindings(scan){
349
440
  commit: s.commit || null,
350
441
  historical: s._historical === true,
351
442
  description: s.description || null,
443
+ // Second independent Finding Provenance PRD audit (Task 7, item 4):
444
+ // `stableId` was emitted for the SAST channel only (below), so a
445
+ // consumer of a secrets/logic/SCA finding could not recompute
446
+ // `findingProvenance.evidenceDigest` (it binds `stableId` as its first
447
+ // input — coordinator.js's `computeDigest`) — the exact case this
448
+ // channel needs it for, since `annotateStableIds(aSecrets)` (engine.js)
449
+ // already backfills a real one before provenance resolution runs.
450
+ stableId: s.stableId || null,
451
+ findingProvenance: _normalizedProvenance(s),
352
452
  });
353
453
  }
354
454
  for (const lv of (scan.logicVulns||[])) {
@@ -376,6 +476,15 @@ export function normalizeFindings(scan){
376
476
  ecosystem: lv.ecosystem || null,
377
477
  license: lv.license || null,
378
478
  description: lv.description || null,
479
+ // Second independent Finding Provenance PRD audit (Task 7, item 4) —
480
+ // same reasoning as the secrets channel above. Note: only the
481
+ // `blameableLogic` subset (engine.js) gets a real backfilled stableId
482
+ // via `annotateStableIds`; the three synthetic-line producers
483
+ // (license-policy:/deploy-platform:/stack-playbook:) never go through
484
+ // that backfill and stay `null` here — honest, since they also stay on
485
+ // the permanent not_available provenance path (posture/CLAUDE.md).
486
+ stableId: lv.stableId || null,
487
+ findingProvenance: _normalizedProvenance(lv),
379
488
  });
380
489
  }
381
490
  for (const sc of (scan.supplyChain||[])) {
@@ -425,6 +534,20 @@ export function normalizeFindings(scan){
425
534
  toxicity: sc.toxicityScore ?? null,
426
535
  toxicityFactors: sc.toxicityFactors || null,
427
536
  toxicityLabel: sc.toxicityLabel || null,
537
+ // Git-origin provenance for the dependency declaration (which commit
538
+ // moved the declared version into the advisory's vulnerable range).
539
+ // Distinct from `sc.provenance`, which sca/sigstore-verify.js uses for
540
+ // the package's Sigstore/SLSA build attestation — that one is NOT
541
+ // carried here today and must not be conflated with this field.
542
+ // Second independent Finding Provenance PRD audit (Task 7, item 4) —
543
+ // same reasoning as the secrets/logic channels above. Only entries
544
+ // routed through annotateGitProvenance (engine.js's `directDeps`/
545
+ // `transitiveDeps`, `isSca`/`isTransitiveSca` in coordinator.js) get a
546
+ // real backfilled `scaStableId`; an entry that was never routed
547
+ // through it (e.g. `unpinned_dep`/`no_lockfile` types) honestly stays
548
+ // `null` here rather than fabricating one.
549
+ stableId: sc.stableId || null,
550
+ findingProvenance: _normalizedProvenance(sc),
428
551
  });
429
552
  }
430
553
  // FR-108: backfill deprecated field names from their current replacements
@@ -676,15 +799,22 @@ export function toCSV(scan){
676
799
  const s = String(v);
677
800
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
678
801
  };
679
- const header = ['id', 'severity', 'vuln', 'cwe', 'cvss', 'owasp', 'file', 'line', 'confidence', 'reachable', 'kind', 'snippet'];
802
+ // FR-PROV-018: a nested findingProvenance object has no natural CSV
803
+ // representation, so this is a deliberate flattening (status/commit/
804
+ // authorDate/confidence), not full fidelity. Appended after the existing
805
+ // columns so a spreadsheet already keyed on column position is unaffected.
806
+ const header = ['id', 'severity', 'vuln', 'cwe', 'cvss', 'owasp', 'file', 'line', 'confidence', 'reachable', 'kind', 'snippet', 'provenanceStatus', 'provenanceCommit', 'provenanceAuthorDate', 'provenanceConfidence'];
680
807
  const rows = [header.join(',')];
681
808
  for (const f of findings) {
809
+ const fp = f.findingProvenance;
682
810
  rows.push([
683
811
  esc(f.id), esc(f.severity), esc(f.vuln), esc(f.cwe), esc(f.cvss || ''),
684
812
  esc(f.owasp || ''), esc(f.file), esc(f.line),
685
813
  esc(f.confidence == null ? '' : f.confidence.toFixed(3)),
686
814
  esc(f.reachable == null ? '' : f.reachable),
687
815
  esc(f.kind), esc((f.snippet || '').slice(0, 200)),
816
+ esc(fp?.status || ''), esc(fp?.findingOrigin?.commit || ''),
817
+ esc(fp?.findingOrigin?.authorDate || ''), esc(fp?.confidence?.level || ''),
688
818
  ].join(','));
689
819
  }
690
820
  return rows.join('\n');
@@ -732,6 +862,15 @@ export function toJUnit(scan, meta={}){
732
862
  return lines.join('\n');
733
863
  }
734
864
 
865
+ // FR-PROV-026: length of the Markdown code fence needed to safely wrap
866
+ // `text` without it being able to break out via an embedded backtick run
867
+ // (e.g. from an unsanitized-for-backticks authorName). Minimum 3, per
868
+ // CommonMark; longer only when `text` itself contains a run that long.
869
+ function _mdFenceLen(text) {
870
+ const runs = String(text == null ? '' : text).match(/`+/g) || [];
871
+ return Math.max(3, ...runs.map(r => r.length + 1));
872
+ }
873
+
735
874
  export function toMarkdown(scan, meta={}){
736
875
  const findings = normalizeFindings(scan);
737
876
  const lines = ['# Agentic Security — Scan Report', ''];
@@ -764,6 +903,32 @@ export function toMarkdown(scan, meta={}){
764
903
  lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
765
904
  }
766
905
  }
906
+ // FR-PROV-018: one provenance block per finding that has one, reusing
907
+ // explainProvenance's content — never a second, divergent renderer.
908
+ const withProvenance = bySev[sev].filter(f => f.findingProvenance);
909
+ if (withProvenance.length) {
910
+ lines.push('');
911
+ lines.push('<details><summary>Provenance</summary>');
912
+ lines.push('');
913
+ for (const f of withProvenance) {
914
+ const block = explainProvenance(f);
915
+ if (!block) continue;
916
+ lines.push(`**\`${f.file}:${f.line}\`** — ${f.vuln}`);
917
+ // FR-PROV-026: sanitizeForTerminal (applied inside explainProvenance)
918
+ // strips control chars but NOT backticks, which are ordinary text —
919
+ // a malicious authorName containing ``` could otherwise break out of
920
+ // a fixed 3-backtick fence and inject raw Markdown/HTML into the
921
+ // report. Use a fence one backtick longer than any run already in
922
+ // the block (CommonMark's own escaping mechanism for fenced code),
923
+ // so normal content (never containing backticks) is unaffected.
924
+ const fence = '`'.repeat(_mdFenceLen(block));
925
+ lines.push(fence);
926
+ lines.push(block);
927
+ lines.push(fence);
928
+ lines.push('');
929
+ }
930
+ lines.push('</details>');
931
+ }
767
932
  lines.push('');
768
933
  }
769
934
  return lines.join('\n');
@@ -841,6 +1006,16 @@ export function toSARIF(scan, meta={}){
841
1006
  ...(scan && scan._rulesetVersion ? { rulesetVersion: scan._rulesetVersion } : {}),
842
1007
  ...(scan && scan._rulesetVersionSource ? { rulesetVersionSource: scan._rulesetVersionSource } : {}),
843
1008
  ...(scan && scan._rulesetVersionMismatch ? { rulesetVersionMismatch: scan._rulesetVersionMismatch } : {}),
1009
+ // FR-PROV-018: run-level provenance summary — how many results
1010
+ // resolved which terminal status, so a SARIF consumer can judge
1011
+ // history coverage without walking every result's properties.
1012
+ ...(findings.some(f => f.findingProvenance) ? {
1013
+ provenanceCoverage: findings.reduce((acc, f) => {
1014
+ const s = f.findingProvenance?.status || 'none';
1015
+ acc[s] = (acc[s] || 0) + 1;
1016
+ return acc;
1017
+ }, {}),
1018
+ } : {}),
844
1019
  },
845
1020
  }],
846
1021
  results: findings.map(f => {
@@ -905,6 +1080,12 @@ export function toSARIF(scan, meta={}){
905
1080
  signatureStatus: f.signatureStatus || (f._passThroughSigning ? 'pass-through' : (f._unsigned ? 'unsigned' : 'verified')),
906
1081
  ...(f._unsigned ? { unsigned: true } : {}),
907
1082
  ...(f._passThroughSigning ? { passThroughSigning: true } : {}),
1083
+ // FR-PROV-018: `f` is already normalized (findingProvenance already
1084
+ // passed through _normalizedProvenance/redactFindingProvenance by
1085
+ // normalizeFindings), so this is a redacted passthrough, never a
1086
+ // second redaction pass and never a read of a raw pre-normalization
1087
+ // finding.
1088
+ ...(f.findingProvenance ? { findingProvenance: f.findingProvenance } : {}),
908
1089
  },
909
1090
  };}),
910
1091
  }],
@@ -966,7 +1147,7 @@ export function toHTML(scan, meta = {}) {
966
1147
  // server-side) so the browser render shows them without a second command.
967
1148
  const findings = normalizeFindings(scan).map(f => {
968
1149
  const ex = explainParts(f, { verbose: true });
969
- return { ...f, _riskNote: riskNote(f), _explainWhy: ex.why, _explainHow: ex.how };
1150
+ return { ...f, _riskNote: riskNote(f), _explainWhy: ex.why, _explainHow: ex.how, _explainProvenance: explainProvenance(f) };
970
1151
  });
971
1152
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
972
1153
  for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
@@ -1044,6 +1225,8 @@ export function toHTML(scan, meta = {}) {
1044
1225
  .f-how{margin-top:6px;color:#94a3b8;font-size:13px}
1045
1226
  .f-how code{font-family:ui-monospace,monospace;color:#e2e8f4}
1046
1227
  .f-fix{background:#0d1f3d;border-left:3px solid #38bdf8;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
1228
+ .f-provenance{background:#0f1f14;border-left:3px solid #34d058;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
1229
+ .f-provenance pre{background:transparent;padding:0;margin:4px 0 0 0}
1047
1230
  .hidden{display:none!important}
1048
1231
  </style></head>
1049
1232
  <body>
@@ -1099,6 +1282,7 @@ function makeCard(f) {
1099
1282
  (f.snippet ? '<pre>' + esc(f.snippet) + '</pre>' : '') +
1100
1283
  (f.masked ? '<pre style="color:#f97316">' + esc(f.masked) + ' (masked)</pre>' : '') +
1101
1284
  (f.fix && f.fix.description ? '<div class="f-fix"><b>Fix:</b> ' + esc(f.fix.description) + (f.fix.code ? '<pre>' + esc(f.fix.code) + '</pre>' : '') + '</div>' : '') +
1285
+ (f._explainProvenance ? '<div class="f-provenance"><b>Provenance:</b><pre>' + esc(f._explainProvenance) + '</pre></div>' : '') +
1102
1286
  '</div>';
1103
1287
  div.addEventListener('click', () => div.classList.toggle('expanded'));
1104
1288
  return div;
@@ -1178,7 +1362,10 @@ const RESET = '\x1b[0m';
1178
1362
  const DIM = '\x1b[2m';
1179
1363
  const BOLD = '\x1b[1m';
1180
1364
 
1181
- export function toCLI(scan, { verbose=false, color=true }={}){
1365
+ // `provenance` defaults to FALSE on purpose: the block is five-plus extra
1366
+ // lines per finding, and the default CLI listing is already dense. It prints
1367
+ // only when the operator asks for it.
1368
+ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
1182
1369
  const findings = normalizeFindings(scan);
1183
1370
  const lines = [];
1184
1371
  const c = (s, code) => color ? `${code}${s}${RESET}` : s;
@@ -1204,6 +1391,13 @@ export function toCLI(scan, { verbose=false, color=true }={}){
1204
1391
  if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
1205
1392
  if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
1206
1393
  if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
1394
+ if (provenance) {
1395
+ // `f` here is already normalized, so its findingProvenance has been
1396
+ // through redactFindingProvenance — the email is gone before it can
1397
+ // reach a terminal.
1398
+ const prov = explainProvenance(f);
1399
+ if (prov) for (const ln of prov.split('\n')) lines.push(` ${c(ln, DIM)}`);
1400
+ }
1207
1401
  }
1208
1402
  lines.push('');
1209
1403
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
package/src/runScan.js CHANGED
@@ -4,6 +4,7 @@ import * as fs from 'node:fs/promises';
4
4
  import * as path from 'node:path';
5
5
  import * as cp from 'node:child_process';
6
6
  import { listFiles } from './util/glob.js';
7
+ import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
7
8
  import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate, isInstructionFile } from './engine.js';
8
9
  import { appendScanSnapshot } from './posture/security-trend.js';
9
10
  import { recover as recoverFixHistory } from './posture/fix-history.js';
@@ -111,17 +112,28 @@ export async function readTree(root, { ignore = [] } = {}) {
111
112
 
112
113
  // Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
113
114
  // repo-relative paths modified since the ref, or null if git is unavailable.
115
+ //
116
+ // `root` is the scan target's repository, not this project's own trusted
117
+ // checkout — hardened per FR-PROV-024 / the second Finding Provenance PRD
118
+ // audit (same exposure class as provenance/git-evidence.js: a hostile
119
+ // .git/config's `core.fsmonitor` fires on the `git status --porcelain`
120
+ // call below just from reading repo state). `--no-ext-diff` is added to the
121
+ // `diff --name-only` call too: VERIFIED that shape does not itself invoke an
122
+ // external diff driver in current git (no content is rendered), but it costs
123
+ // nothing and keeps every `git diff` call site in this codebase uniformly
124
+ // hardened against the surface `material-change.js`'s `classifyGitDiff` was
125
+ // actually caught on (see that file's comment for the live exploit).
114
126
  export function changedSince(root, gitRef) {
115
127
  if (!gitRef) return null;
116
128
  try {
117
- const out = cp.execFileSync('git', ['diff', '--name-only', `${gitRef}...HEAD`], {
118
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
129
+ const out = cp.execFileSync('git', hardenGitArgs(['diff', '--name-only', '--no-ext-diff', `${gitRef}...HEAD`]), {
130
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
119
131
  });
120
132
  const set = new Set(out.split('\n').filter(Boolean));
121
133
  // Also include uncommitted changes
122
134
  try {
123
- const dirty = cp.execFileSync('git', ['status', '--porcelain'], {
124
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
135
+ const dirty = cp.execFileSync('git', hardenGitArgs(['status', '--porcelain']), {
136
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
125
137
  });
126
138
  for (const line of dirty.split('\n')) {
127
139
  const f = line.slice(3).trim();
@@ -151,9 +163,22 @@ export async function runScan(rootDir, opts = {}) {
151
163
  // Caller may pre-build fileContents (used by the MCP server's scan_diff to
152
164
  // scope a scan to a specific file list without walking the whole tree).
153
165
  let fileContents, depFileContents;
166
+ // `completeScan` answers ONE question for the whole pipeline: does the file
167
+ // set below cover all of `root`, or only a subset of it? Anything downstream
168
+ // that reasons about the ABSENCE of a finding — most importantly the
169
+ // provenance lifecycle ledger, whose remediation pass closes every open
170
+ // stableId missing from this scan — is only sound on a complete scan. A
171
+ // subset scan that claims completeness marks the entire rest of the project
172
+ // remediated. It starts true and is only ever narrowed, so a new subsetting
173
+ // path added later must opt OUT explicitly rather than silently inherit a
174
+ // false claim of coverage.
175
+ let completeScan = true;
154
176
  if (opts.fileContents) {
177
+ // Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
178
+ // construction a subset of the tree, not a scan of it.
155
179
  fileContents = opts.fileContents;
156
180
  depFileContents = opts.depFileContents || {};
181
+ completeScan = false;
157
182
  } else {
158
183
  ({ fileContents, depFileContents } = await readTree(root, opts));
159
184
  }
@@ -167,6 +192,10 @@ export async function runScan(rootDir, opts = {}) {
167
192
  if (changed.has(f)) filtered[f] = fileContents[f];
168
193
  }
169
194
  fileContents = filtered;
195
+ // Only when the filter actually applied. A `changedSince` that resolved
196
+ // to null (not a git repo / bad ref) is warned about below and scans the
197
+ // whole tree, which IS complete.
198
+ completeScan = false;
170
199
  } else if (opts.onProgress) {
171
200
  opts.onProgress({ phase: 'warning', file: 'changedSince ignored: not a git repo or invalid ref', current: 0, total: 0 });
172
201
  }
@@ -174,7 +203,7 @@ export async function runScan(rootDir, opts = {}) {
174
203
 
175
204
  // R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
176
205
  // AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
177
- const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi }, opts.onProgress || (()=>{}));
206
+ const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan }, opts.onProgress || (()=>{}));
178
207
  // Premortem 2R4.2: stamp ruleset version + source on the scan result, and
179
208
  // notify if the operator pinned a different version than what's installed.
180
209
  try { stampScan(root, scan); } catch {}
@@ -31,7 +31,13 @@ const AI_PATH_RE = /\/(?:ai|chat|generate|complete|completion|embed|embedding|gp
31
31
  const PAYMENT_PATH_RE = /\/(?:pay(?:ment)?|checkout|stripe|order|subscribe|billing|invoice|charge|purchase)\b/i;
32
32
  const CONTACT_PATH_RE = /\/(?:contact|submit|feedback|form|newsletter|subscribe|waitlist|signup|onboard)\b/i;
33
33
 
34
- function _hasRateLimit(content) {
34
+ // Exported (FR-PROV-017) so posture/provenance/coordinator.js can reuse the
35
+ // EXACT SAME presence test as a `resolveMissingControl` predicate: "did a
36
+ // commit's historical blob have rate limiting" must be answered by the same
37
+ // logic that decides "does HEAD have rate limiting", or a drift between the
38
+ // two could fabricate a regression (or miss a real one) that only existed in
39
+ // the predicate's own disagreement with the detector, not in the code.
40
+ function hasRateLimit(content) {
35
41
  return RL_IMPORT_RE.test(content) || RL_USAGE_RE.test(content) || REDIS_RL_RE.test(content);
36
42
  }
37
43
 
@@ -77,7 +83,7 @@ const CATEGORY_META = {
77
83
  function scanRateLimit(file, content) {
78
84
  if (!_SCAN_EXT_RE.test(file)) return [];
79
85
  if (_NONPROD_RE.test(file)) return [];
80
- if (_hasRateLimit(content)) return [];
86
+ if (hasRateLimit(content)) return [];
81
87
  const findings = [];
82
88
  const lines = content.split('\n');
83
89
 
@@ -99,6 +105,18 @@ function scanRateLimit(file, content) {
99
105
  description: meta.description,
100
106
  remediation: meta.remediation,
101
107
  cwe: meta.cwe,
108
+ // FR-PROV-017: routes posture/provenance/coordinator.js to
109
+ // resolveMissingControl instead of the plain SAST origin-resolver.
110
+ // "A route lacks rate limiting" is exactly the "was this control
111
+ // ever present, and if so when did it disappear" question that
112
+ // resolver answers — a plain SAST resolver would instead ask "when
113
+ // was this LINE introduced," which is the wrong question for a
114
+ // finding about something ABSENT. An explicit boolean marker set
115
+ // here (rather than coordinator.js string-matching finding.id/vuln)
116
+ // keeps the two modules' string formats decoupled, matching how
117
+ // isDirect/isTransitiveSca-style markers already route elsewhere in
118
+ // this pipeline.
119
+ missingControlCandidate: true,
102
120
  });
103
121
  }
104
122
  }
@@ -120,6 +138,18 @@ function scanRateLimit(file, content) {
120
138
  description: meta.description,
121
139
  remediation: meta.remediation,
122
140
  cwe: meta.cwe,
141
+ // FR-PROV-017: routes posture/provenance/coordinator.js to
142
+ // resolveMissingControl instead of the plain SAST origin-resolver.
143
+ // "A route lacks rate limiting" is exactly the "was this control
144
+ // ever present, and if so when did it disappear" question that
145
+ // resolver answers — a plain SAST resolver would instead ask "when
146
+ // was this LINE introduced," which is the wrong question for a
147
+ // finding about something ABSENT. An explicit boolean marker set
148
+ // here (rather than coordinator.js string-matching finding.id/vuln)
149
+ // keeps the two modules' string formats decoupled, matching how
150
+ // isDirect/isTransitiveSca-style markers already route elsewhere in
151
+ // this pipeline.
152
+ missingControlCandidate: true,
123
153
  });
124
154
  }
125
155
  }
@@ -127,4 +157,4 @@ function scanRateLimit(file, content) {
127
157
  return findings;
128
158
  }
129
159
 
130
- export { scanRateLimit };
160
+ export { scanRateLimit, hasRateLimit };
@@ -0,0 +1,128 @@
1
+ // Hardening for every `git` subprocess call this scanner makes against a
2
+ // SCANNED repository it does not control — i.e. any git invocation whose
3
+ // `cwd` (or `-C <dir>`) is a project the scanner was asked to scan, as
4
+ // opposed to this project's own trusted checkout.
5
+ //
6
+ // Verified RCE (second independent Finding Provenance PRD audit,
7
+ // FR-PROV-024 / PRD Section 8 "never run repository hooks or untrusted
8
+ // build scripts"): git-evidence.js's `_run` invoked `git` with no config
9
+ // hardening at all. A hostile repo's `.git/config` can set `core.fsmonitor`
10
+ // to point at an attacker script; git executes it on an ordinary READ-ONLY
11
+ // command like `git status --porcelain` — no clone, no checkout, no
12
+ // deliberate command needed, just `getRepoState()` reading repo state.
13
+ // Reproduced against this exact repro shape before this module existed:
14
+ // `getRepoState()` alone wrote a marker file outside the repo.
15
+ //
16
+ // Four independent hostile-config surfaces, each closed by a different
17
+ // flag/env var (do not assume one covers another — a same-class RCE
18
+ // survived the first round of this hardening precisely because
19
+ // `--no-textconv` was assumed to cover `git diff` the same way it covers
20
+ // `git show`/`git log -p`/`git blame`, and it does not):
21
+ // - `core.fsmonitor` -> fires on `git status` (and other porcelain
22
+ // commands that consult the index). Closed by
23
+ // `-c core.fsmonitor=` (empty value disables it).
24
+ // - `core.hooksPath` -> redirects git's hook lookup to an
25
+ // attacker-controlled directory (pre-commit,
26
+ // post-checkout, ...). None of THIS module's
27
+ // read-only operations should fire a hook, but a
28
+ // caller elsewhere in the tree that does invoke a
29
+ // hook-shaped command (checkout, commit) inherits
30
+ // the same exposure — hardened uniformly rather
31
+ // than relying on each call site to reason about
32
+ // whether its own command can trigger a hook.
33
+ // Closed by `-c core.hooksPath=/dev/null`
34
+ // (verified: git tries to stat
35
+ // `/dev/null/<hookname>`, which is not a
36
+ // directory, so hook lookup fails closed — this
37
+ // is NOT relying on /dev/null being an empty
38
+ // *file*, it works because it isn't a directory).
39
+ // - `.gitattributes` -> a `diff=<name>` attribute + a matching
40
+ // textconv driver `diff.<name>.textconv` config key points a text
41
+ // filter at an attacker script; fires on any
42
+ // command that renders blob/diff CONTENT (`git
43
+ // show`/`git diff`/`git log -p`/`git log -L`/
44
+ // `git blame`) unless `--no-textconv` is passed.
45
+ // Verified per-subcommand: `git show -s` (no
46
+ // content shown) and `git show <sha>:<path>`
47
+ // (blob cat, not a diff) were NOT exploitable in
48
+ // this git version, but `git show -U0`, `git log
49
+ // -L`, and `git blame` all were.
50
+ // - `.git/config` / an EXTERNAL diff driver — `.gitattributes`
51
+ // `.gitattributes` `diff=<name>` + `.git/config [diff "<name>"]
52
+ // external diff driver command=<script>`, or the repo-local/global
53
+ // `diff.external` config key — is a DIFFERENT
54
+ // mechanism from the textconv driver above and is
55
+ // NOT closed by `--no-textconv`. VERIFIED: `git
56
+ // -c core.fsmonitor= -c core.hooksPath=/dev/null
57
+ // diff --unified=0 --no-textconv <ref>...HEAD`
58
+ // still runs the attacker's `diff.evil.command`
59
+ // script — `--no-textconv` only suppresses the
60
+ // TEXTCONV driver, and `git diff` (unlike `git
61
+ // show`/`git log -p`/`git blame`, which were all
62
+ // verified safe with just `--no-textconv`) honours
63
+ // an external diff driver by default regardless.
64
+ // Closed by `--no-ext-diff`, which must be passed
65
+ // explicitly on every `git diff` invocation (same
66
+ // reason `--no-textconv` isn't a `-c` flag: it's a
67
+ // diff-machinery option, not repo config). This
68
+ // was the live RCE a second review found after the
69
+ // first round of this hardening shipped —
70
+ // material-change.js's `classifyGitDiff` (the real
71
+ // entry point for `/scan --diff`) had
72
+ // `--no-textconv` but not `--no-ext-diff` and was
73
+ // still exploitable end-to-end.
74
+ //
75
+ // A FIFTH surface is known but not exploitable through any call site in this
76
+ // codebase today, so it is documented rather than closed: a `clean` smudge
77
+ // filter (`.gitattributes` `filter=<name>` + `filter.<name>.clean`) fires on
78
+ // a WORKTREE diff (e.g. `git diff --name-only HEAD` with no `<ref>` on the
79
+ // other side) and has no git flag to disable it at all (unlike textconv/
80
+ // ext-diff). Every `git diff` call site in this codebase diffs two refs
81
+ // (`<ref>...HEAD`), never the worktree against HEAD, so nothing here hits
82
+ // it — but a future worktree-diff call site would silently reintroduce this
83
+ // exact vulnerability class and must not assume `hardenGitArgs` covers it.
84
+ //
85
+ // `GIT_CONFIG_NOSYSTEM=1` additionally blocks a SYSTEM-level git config
86
+ // (outside any repository, e.g. /etc/gitconfig) from re-introducing a
87
+ // hostile setting the `-c` flags above didn't anticipate; `GIT_TERMINAL_PROMPT=0`
88
+ // stops a git invocation from ever blocking on an interactive credential
89
+ // prompt (a hostile repo pointing `origin`/a submodule at a URL that
90
+ // prompts). Both are environment variables, not `-c` flags — git does not
91
+ // expose either as repo-local config.
92
+ //
93
+ // Every `git` subprocess call this scanner makes against a scan target's
94
+ // repository MUST route its args through `hardenGitArgs` and its env
95
+ // through `hardenGitEnv`. A `git show`/`git diff`/`git log -p`/`git log -L`/
96
+ // `git blame` invocation must ALSO pass `--no-textconv` explicitly (it is
97
+ // not a `-c` config value, so it isn't folded into `GIT_HARDENED_CONFIG_ARGS`
98
+ // — it must appear in the invocation's own args, after the config args), and
99
+ // a `git diff` invocation must ADDITIONALLY pass `--no-ext-diff` (a
100
+ // different flag for a different surface — see above; `--no-textconv` does
101
+ // not imply it).
102
+
103
+ export const GIT_HARDENED_CONFIG_ARGS = Object.freeze([
104
+ '-c', 'core.fsmonitor=',
105
+ '-c', 'core.hooksPath=/dev/null',
106
+ ]);
107
+
108
+ export const GIT_HARDENED_ENV = Object.freeze({
109
+ GIT_CONFIG_NOSYSTEM: '1',
110
+ GIT_TERMINAL_PROMPT: '0',
111
+ });
112
+
113
+ // Prepend the hardening `-c` flags to a git argv. These are GLOBAL options
114
+ // and must appear before the subcommand, which is why this always prepends
115
+ // rather than appending — a `-c` after the subcommand name is parsed as a
116
+ // positional argument to that subcommand, not a global option.
117
+ export function hardenGitArgs(args) {
118
+ return [...GIT_HARDENED_CONFIG_ARGS, ...(Array.isArray(args) ? args : [])];
119
+ }
120
+
121
+ // Build the `env` option for execFileSync/spawnSync. Node's `env` option
122
+ // REPLACES the child's environment rather than merging with it, so this
123
+ // always spreads `process.env` first — passing `hardenGitEnv()` with no
124
+ // argument must be behaviourally identical to inheriting the parent
125
+ // environment plus the two hardening vars, never a stripped-down one.
126
+ export function hardenGitEnv(extraEnv) {
127
+ return { ...process.env, ...GIT_HARDENED_ENV, ...(extraEnv || {}) };
128
+ }