@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -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
  }
@@ -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,47 @@
1
+ # scanner/src/server/
2
+
3
+ The `agentic-security explore` local server — Milestone 3 of the Data
4
+ Flow Explorer PRD ("local server and secure API," PRD §17). Read-only in
5
+ this increment; serves the already-scanned, already-signed
6
+ `DataFlowGraph v1` artifact over a hardened loopback HTTP API.
7
+
8
+ ## What's here
9
+
10
+ **Sub-project Server, increment 1 (read-only GET endpoints + full
11
+ security posture) — COMPLETE:**
12
+
13
+ | Module | Responsibility |
14
+ |---|---|
15
+ | `docs/superpowers/plans/2026-09-01-data-flow-explorer-m3-server-scoping.md` + `…-s1-plan.md` | Confirms the entire deliverable was greenfield (no HTTP server code anywhere; the MCP server is JSON-RPC-over-stdio, the LSP server a different stdio protocol — neither has an HTTP surface). Scopes S1 to the five P0 GET endpoints only, deferring `query`/`export` (real design work of their own) to S2/M4. Every §17.4 security bullet is a first-class S1 requirement, not a follow-up pass, since `docs/DATA_FLOW_EXPLORER_THREAT_MODEL.md`'s own T2 (DNS rebinding)/T3 (CSRF)/T4 (path traversal) entries are explicitly gated on "the server does not exist yet." |
16
+ | `graph-loader.js` | `loadSignedGraph(scanRoot)` — reads `.agentic-security/lineage-graph.json` + `.sig`, reusing `posture/integrity.js`'s `verifyLastScan` DIRECTLY (never a second, parallel verification mechanism — that function already uses `crypto.timingSafeEqual` internally). Four distinct, clearly-messaged failure reasons: `missing` (no file), `unsigned` (`.sig` absent), `tampered` (signature mismatch), `malformed` (valid signature, invalid JSON). Loaded once at server startup, held in memory — this is a read-only, single-scan-snapshot server; a change to the graph on disk mid-session is out of scope (the threat model doc's own "P0 is read-only" framing). |
17
+ | `security.js` | `generateSessionToken()` (32 random bytes, hex); `constantTimeEqual(a, b)` (wraps `crypto.timingSafeEqual`, length-guarded the same way `verifyLastScan` guards its own HMAC comparison — never throws on mismatched-length input); `isValidHost(hostHeader, expectedPort)` — the T2 DNS-rebinding defense, accepting only `127.0.0.1`/`localhost`/`::1` (bare or bracketed) at the exact expected port, rejecting everything else including alternate-encoded loopback forms (decimal/octal/hex IP literals) — a hostile page's own Host header carries ITS OWN origin, never `127.0.0.1`, so this check defeats DNS rebinding regardless of browser same-origin-policy quirks; `CSP_HEADER_VALUE = "default-src 'none'; frame-ancestors 'none'"` (maximally restrictive — this server serves JSON only in S1, no HTML/inline script surface at all). |
18
+ | `routes.js` | Six PURE handler functions, each `(graph, ...) -> {status, body}` — `handleScan`, `handleGraph`, `handleNode`, `handleEdge`, `handleFlow`, `handleQuery` — no `req`/`res` access anywhere in this file, independently unit-testable without an HTTP layer. `wrapResponse(data, graph, {canonicalIds})` wraps every body in the exact envelope PRD line 1326 requires ("base graph/snapshot digest, schema/extension versions, scope, coverage, limitations, and contributing canonical IDs"), mapped onto the graph's own real fields (`graphId`→digest, `schemaVersion`, `extensions`, `scope`, `coverage`, `limitations`). `canonicalIds` is `null` for `handleScan`/`handleGraph`/`handleQuery` (all three describe the whole graph or a filtered projection of it — `handleGraph`'s own body already IS the full node/edge/flow arrays, so re-listing every id would be pure duplication with real cost on a large graph), `[id]` for `handleNode`/`handleEdge`, and the flow's own id plus its `source`/`sink`/`edgeIds` for `handleFlow` (a derived record referencing several other entities — naming them is genuinely useful metadata, not duplication). **`handleQuery(graph, filter)`** (Milestone 5, large-graph pagination — the `POST /api/v1/query` endpoint the S1-era header comment above and this file's own "What this does NOT do" section used to defer) shape-validates `filter` via `export-json.js`'s `validateFilterShape` (400 on a malformed filter, never a thrown exception) and, when valid, narrows the graph via that same module's already-shipped, already-tested `_filterGraph` — the identical `{nodeIds, edgeIds}` shape `dataflow export --filter` and the MCP `dataflow_get_graph` tool already use, reused rather than reimplemented. `undefined`/`{}` returns the whole graph, identical to `handleGraph`. |
19
+ | `http-server.js` | `createExploreServer({graph, port, sessionToken, idleTimeoutMs, keepOpen})` — the first `node:http` usage anywhere in `scanner/src/`. Binds explicitly to `127.0.0.1` (never `0.0.0.0`, never an omitted host argument — that is NOT the same as `127.0.0.1` on every platform). Every request: `Host` header validated (T2, 400 on failure) BEFORE the session token (T3, 401 on failure, `constantTimeEqual`) BEFORE a uniform request-size cap (`MAX_REQUEST_BODY_BYTES = 64KB`, 413 on excess — S1's GET endpoints have no meaningful body; Milestone 5's `POST /api/v1/query` is the first real consumer of the body-accumulation this cap now gates) BEFORE routing. `POST /api/v1/query`'s request body is accumulated alongside the pre-existing size-cap check and JSON-parsed only for a matched route with a non-empty body (malformed JSON → 400, never a 500) — every pre-M5 GET route still ignores the handler's 3rd `body` argument entirely, so an empty/missing body for them stays a no-op, unchanged. Every response: `Content-Security-Policy` set, `Cache-Control: no-store` set, `Access-Control-Allow-Origin` NEVER set anywhere in this file (CORS stays disabled by the header's own absence — do not add a permissive default later without re-reading this line). Idle-timeout auto-stop (`DEFAULT_IDLE_TIMEOUT_MS = 30 min`, no PRD-specified number exists, a disclosed reasoned default) resets ONLY on requests that pass BOTH the Host and token checks — an unauthenticated prober (a DNS-rebinding attempt, a stray scan) can never keep the server alive by hammering it; only genuine authenticated activity extends the session. `keepOpen: true` suppresses auto-stop entirely. Logging is metadata-only (method/path/status/duration) — never response bodies, never the session token, even on a failed-auth attempt. A 500 from a route handler returns a generic `{error: 'internal error'}` body, never the underlying exception's message/stack. **Disclosed, non-security gap**: no `SIGINT`/`SIGTERM` handler exists yet, so Ctrl-C relies on Node's default abrupt-exit rather than a graceful `server.close()` — functionally fine (the process does exit) but not maximally graceful; a future increment could add one for a cleaner shutdown message. |
20
+ | `bin/agentic-security.js` (extended, additively) | `case 'explore':` + `cmdExplore(args)` — parses the scan root (`args._[1]`, default cwd), `--port` (validated: digits-only, ≤65535), `--keep-open`. Calls `loadSignedGraph` FIRST; on any failure, prints the specific clear message and exits non-zero WITHOUT ever starting the server (never binds a port, never generates a token, on a missing/tampered/unsigned/malformed graph). On success, generates a fresh session token per invocation and prints it to stdout EXACTLY ONCE — the only place it is ever displayed, never written to a file, never logged again after this one print. Returns a Promise resolving to exit code 0 when the server eventually closes (idle-timeout or an external kill), matching every other `cmdX(args)` function's own "returns a numeric exit code" contract while still supporting a long-running process. `explore` never triggers a scan itself — it requires a pre-existing artifact, matching §17.3's own "P0 is read-only with respect to source" framing exactly. |
21
+ | `test/server/*.test.js` (41 tests) | `security.test.js`/`graph-loader.test.js`/`routes.test.js` — pure-function unit tests (isolated `constantTimeEqual`/`isValidHost` edge cases; all four `loadSignedGraph` failure reasons via a real signed/tampered/unsigned/malformed fixture; all five route handlers' shapes). `http-server.test.js` — REAL end-to-end `node:http.request` calls against a REAL running server (never a mocked/isolated unit test for the security-critical properties): a forged `Host` header rejected live (T2); a missing AND a wrong session token rejected live on every one of the five endpoints (T3); response headers asserted present/absent on BOTH a 200 and a rejected response; a real short-idle-timeout variant proving the server genuinely stops listening and refuses new connections (`ECONNREFUSED`), and a `keepOpen: true` variant proving it doesn't; an oversized-body 413. `cmd-explore.test.js` — the real spawned CLI end to end (missing graph → non-zero exit, no token ever printed, connection refused; a valid signed graph → a real server whose printed URL + token authenticate a real request). Independently re-verified by the coordinator via a THIRD path beyond the implementer's own tests: a real `explore` invocation launched manually, hit with `curl` from outside any test harness, confirming the same 200/401/400 outcomes and headers live. |
22
+
23
+ **Sub-project Wire (live-API wiring for the frontend) — COMPLETE:**
24
+
25
+ | Module | Responsibility |
26
+ |---|---|
27
+ | `docs/superpowers/plans/2026-09-01-data-flow-explorer-m3-wire-scoping.md` + `…-plan.md` | The load-bearing correction this increment starts from: §17.4's CORS-disabled-by-default posture means the frontend MUST be served from the SAME origin as the API, or the browser blocks every `fetch()` regardless of what the server returns. Decides the session token travels via a URL FRAGMENT (`#token=...`), never a query string or cookie — a fragment is never sent to the server in any HTTP request. |
28
+ | `static-assets.js` | `FRONTEND_ROOT` (located via the exact `path.dirname(fileURLToPath(import.meta.url))` pattern `mcp/server.js` already established, resolved to the real `frontend/` directory). `resolveStaticAsset(requestPath)` — a PURE function, no fs/http access — resolving a request pathname against an explicit ALLOWLIST (`index.html`; `src/**/*.js`; top-level `styles/*.css` only, no nested subdirs) and returning `{ok:true, relativePath, contentType}` or `{ok:false, reason}`. This is a real allowlist, not merely a `../`-traversal guard: `test/`, `scripts/`, `package.json`, `README.md`, `CLAUDE.md`, `.gitignore` are all real files that exist on disk under `frontend/` and are REJECTED (`reason: 'not-allowlisted'`) even though a naive "stays inside frontend/" check would happily serve them. Traversal defense re-checks AFTER `path.posix.normalize` (never trusts normalization alone) for both literal and percent-encoded (`%2e%2e`) `..`; null bytes are rejected both literally and as a decoded `%00`; absolute-URL-looking input (a scheme, or `//host/...`) is rejected outright. `STATIC_CSP_HEADER_VALUE` is a SEPARATE, same-origin-permitting CSP (`script-src 'self'` etc., no `'unsafe-inline'`) from `security.js`'s `CSP_HEADER_VALUE` (`default-src 'none'`, unchanged, still used on every `/api/v1/*` response). |
29
+ | `http-server.js` (extended) | New static-asset branch, checked for every `GET` request whose path does NOT start with `/api/` — resolved BEFORE the session-token check (the ONE deliberate, disclosed exception to "every request requires a token": the token lives in a URL fragment the browser never sends, so the very first page-load request is structurally incapable of carrying it) but AFTER the Host-header check (T2 applies uniformly, auth or not). A rejected static path is a 404, never a 403 (never confirms a path's existence to a prober). Static responses carry `STATIC_CSP_HEADER_VALUE` + `Cache-Control: no-store` (unchanged) + the resolved Content-Type; `Access-Control-Allow-Origin` is still never set. The idle-auto-stop timer is deliberately NOT reset by an unauthenticated static-asset request — only genuine, authenticated `/api/v1/*` activity extends the session, unchanged from S1's own invariant. |
30
+ | `bin/agentic-security.js`'s `cmdExplore` (extended) | The printed URL changed from `http://127.0.0.1:<port>/api/v1/scan` to `http://127.0.0.1:<port>/#token=<64-hex-token>` — still the ONE place the token is ever displayed, now carried as a URL fragment instead of a separate "Token:" line, with updated wording ("open this URL in a browser" rather than "pass the token as a header manually"). |
31
+ | `frontend/src/lib/api-client.js` (new, in `frontend/`) | `extractTokenFromFragment()` (parses `location.hash` for `#token=<hex>`, tolerant of other hash params shell.js may also be writing) and `fetchGraph({token, baseUrl})` (fetches `/api/v1/graph` with the token header, unwraps the `.data` envelope field). `baseUrl` defaults to `''` (a same-origin relative fetch — the only thing the shipped page itself ever passes) and exists solely so a test can point this at a real running server without a headless browser. |
32
+ | `frontend/src/main.js` (new, in `frontend/`) | Replaces `index.html`'s old inline `<script type="module">` block (which would have needed `'unsafe-inline'` under `STATIC_CSP_HEADER_VALUE`) with an external module: extracts the token, calls `fetchGraph`, calls the EXISTING, UNCHANGED `bootstrap(root, graph)`, and shows a plain visible error in `#app-root` on fetch failure — never a silent blank page. |
33
+ | `test/server/static-assets.test.js` (new) | Pure-function tests — every real allowlisted path resolves with the right content type; several REAL on-disk-but-not-allowlisted files (`test/dom-shim.js`, `package.json`, `README.md`, `CLAUDE.md`, `.gitignore`, the generator script) are rejected; traversal (several encodings, including percent-encoded `..` and multi-segment escapes) and null-byte (literal and `%00`) rejection. |
34
+ | `test/server/http-server.test.js` (extended) | Real, live HTTP requests against a real running server for the new surface: `GET /` with NO token succeeds and serves the real `index.html`; `GET /api/v1/graph` with no token still 401 (the exemption is static-route-only); static responses carry the separate static CSP, never `default-src 'none'`; a forged `Host` header is still rejected on a static route; a live `../`-traversal attempt is rejected 404; the same on-disk-but-unlisted files are rejected 404 over real HTTP, never 403. |
35
+ | `test/server/cmd-explore.test.js` (updated) | Re-asserts the CLI's printed-URL regex against the new fragment-token form and that no separate "Token:" line is ever printed. |
36
+ | `frontend/test/api-client.test.js`, `frontend/test/live-fetch-parity.test.js` (new, in `frontend/`) | `extractTokenFromFragment`/`fetchGraph` unit tests (mocked fetch) plus the AC-16 proof: a REAL `explore` server started over the REAL flagship-graph fixture, fetched through the REAL `api-client.js` over a REAL loopback HTTP connection (no mock), asserted content-identical to the static-import baseline, then rendered through the SAME, UNCHANGED `bootstrap()` and asserted to produce a structurally IDENTICAL DOM tree to the existing static-import baseline's own render. |
37
+
38
+ ## What this does NOT do
39
+
40
+ `POST /api/v1/export` (the self-contained-export pipeline, §17.5 — Milestone 4 territory per the parent M3 scoping doc). Any Milestone 4/5 decision-intelligence endpoint (`stories/`, `scenarios/`, `snapshots/`, `diffs/`, `obligations/`, `runtime/`, `recipients/`, `impact/`, `remediation/`, `rescan`). Wiring `frontend/` to this server (Sub-project Wire's own job — this server is built and tested standalone, in complete isolation from `frontend/`). File/line evidence path resolution and its own T4 path-traversal confinement test (no such lookup exists in S1's five endpoints — the graph JSON is served verbatim, so T4 correctly has no surface yet, the same honest status the threat model doc itself records). Triggering a scan from `explore` itself. Any language beyond what the graph itself already covers (this server is language-agnostic — it serves whatever graph exists).
41
+
42
+ ## Conventions
43
+
44
+ - **No new npm dependency, ever, without re-opening this decision first.** `node:http`/`node:crypto`/`node:fs` only — matches this repo's own minimalism convention (`validate.js`'s own "no new npm dependency" precedent) and the MCP/LSP servers' own hand-rolled-protocol precedent. If a future increment genuinely needs a framework, that is a real, separately-justified decision, not a default.
45
+ - **CORS stays disabled by the ABSENCE of `Access-Control-Allow-Origin`.** Never add this header with a permissive value (`*`, or an echoed `Origin`) anywhere in this package without a real, disclosed threat-model reason — its absence is load-bearing security posture, not an oversight.
46
+ - **Every security-relevant string comparison goes through `constantTimeEqual`** (`security.js`), never `===`/`!==` on a token or secret.
47
+ - **A route handler's thrown error must never reach the client verbatim** — `http-server.js`'s own catch-and-genericize behavior around route dispatch is load-bearing; a future endpoint must not bypass it.