@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.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 (201) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +37 -0
  3. package/dist/119.index.js +197 -0
  4. package/dist/128.index.js +129 -0
  5. package/dist/1293.index.js +5689 -0
  6. package/dist/1351.index.js +133 -0
  7. package/dist/1387.index.js +271 -0
  8. package/dist/1437.index.js +23 -0
  9. package/dist/1445.index.js +110 -0
  10. package/dist/1505.index.js +151 -0
  11. package/dist/1557.index.js +293 -0
  12. package/dist/1621.index.js +311 -0
  13. package/dist/1751.index.js +160 -0
  14. package/dist/1765.index.js +727 -0
  15. package/dist/1861.index.js +690 -0
  16. package/dist/1898.index.js +116 -0
  17. package/dist/1906.index.js +433 -0
  18. package/dist/1920.index.js +1 -291
  19. package/dist/208.index.js +1631 -0
  20. package/dist/2199.index.js +0 -239
  21. package/dist/2223.index.js +159 -0
  22. package/dist/2530.index.js +152 -0
  23. package/dist/2565.index.js +116 -0
  24. package/dist/2574.index.js +127 -0
  25. package/dist/2620.index.js +345 -0
  26. package/dist/2773.index.js +98 -0
  27. package/dist/2775.index.js +32 -0
  28. package/dist/2966.index.js +171 -0
  29. package/dist/2972.index.js +87 -0
  30. package/dist/2991.index.js +131 -0
  31. package/dist/3063.index.js +833 -0
  32. package/dist/3075.index.js +227 -0
  33. package/dist/3121.index.js +297 -0
  34. package/dist/3314.index.js +263 -0
  35. package/dist/3480.index.js +1822 -0
  36. package/dist/3481.index.js +24 -0
  37. package/dist/3506.index.js +268 -0
  38. package/dist/3533.index.js +591 -0
  39. package/dist/3653.index.js +450 -0
  40. package/dist/3822.index.js +131 -0
  41. package/dist/3868.index.js +292 -0
  42. package/dist/3917.index.js +152 -0
  43. package/dist/3918.index.js +192 -0
  44. package/dist/4090.index.js +288 -0
  45. package/dist/4121.index.js +1190 -0
  46. package/dist/4142.index.js +0 -239
  47. package/dist/4336.index.js +436 -0
  48. package/dist/4387.index.js +532 -0
  49. package/dist/4413.index.js +302 -0
  50. package/dist/4476.index.js +4510 -0
  51. package/dist/4491.index.js +133 -0
  52. package/dist/4538.index.js +185 -0
  53. package/dist/4684.index.js +319 -0
  54. package/dist/4740.index.js +165 -0
  55. package/dist/4780.index.js +209 -0
  56. package/dist/4802.index.js +225 -0
  57. package/dist/4812.index.js +379 -0
  58. package/dist/4835.index.js +126 -0
  59. package/dist/494.index.js +226 -0
  60. package/dist/4991.index.js +549 -0
  61. package/dist/5199.index.js +305 -0
  62. package/dist/5216.index.js +109 -0
  63. package/dist/5439.index.js +301 -0
  64. package/dist/5443.index.js +97 -0
  65. package/dist/5496.index.js +377 -0
  66. package/dist/550.index.js +2 -1
  67. package/dist/5801.index.js +588 -0
  68. package/dist/5858.index.js +223 -0
  69. package/dist/5933.index.js +143 -0
  70. package/dist/601.index.js +23 -0
  71. package/dist/6173.index.js +273 -0
  72. package/dist/6291.index.js +1871 -0
  73. package/dist/6312.index.js +666 -0
  74. package/dist/634.index.js +16182 -0
  75. package/dist/644.index.js +87 -0
  76. package/dist/6508.index.js +283 -0
  77. package/dist/6560.index.js +645 -0
  78. package/dist/6576.index.js +422 -0
  79. package/dist/6579.index.js +271 -0
  80. package/dist/6745.index.js +305 -0
  81. package/dist/6818.index.js +21642 -0
  82. package/dist/6972.index.js +167 -0
  83. package/dist/6995.index.js +163 -0
  84. package/dist/7249.index.js +191 -0
  85. package/dist/7372.index.js +1197 -0
  86. package/dist/7546.index.js +541 -0
  87. package/dist/76.index.js +448 -0
  88. package/dist/7879.index.js +163 -0
  89. package/dist/8025.index.js +130 -0
  90. package/dist/8054.index.js +248 -0
  91. package/dist/8097.index.js +201 -0
  92. package/dist/8126.index.js +191 -0
  93. package/dist/8178.index.js +78 -0
  94. package/dist/818.index.js +212 -0
  95. package/dist/8382.index.js +93 -0
  96. package/dist/8408.index.js +261 -0
  97. package/dist/8540.index.js +174 -0
  98. package/dist/8635.index.js +802 -0
  99. package/dist/8688.index.js +410 -0
  100. package/dist/8699.index.js +779 -0
  101. package/dist/8891.index.js +457 -0
  102. package/dist/8916.index.js +885 -0
  103. package/dist/9146.index.js +170 -0
  104. package/dist/9168.index.js +841 -0
  105. package/dist/9292.index.js +369 -0
  106. package/dist/9382.index.js +135 -11
  107. package/dist/9422.index.js +866 -0
  108. package/dist/9440.index.js +453 -0
  109. package/dist/9479.index.js +152 -0
  110. package/dist/9481.index.js +220 -0
  111. package/dist/9539.index.js +100 -0
  112. package/dist/9547.index.js +103 -0
  113. package/dist/9560.index.js +41 -61
  114. package/dist/9692.index.js +90 -0
  115. package/dist/9800.index.js +213 -1
  116. package/dist/9899.index.js +138 -0
  117. package/dist/9981.index.js +206 -0
  118. package/dist/999.index.js +117 -0
  119. package/dist/agentic-security.mjs +27 -27
  120. package/dist/agentic-security.mjs.sha256 +1 -1
  121. package/package.json +31 -6
  122. package/src/context-intelligence/candidates.js +2 -1
  123. package/src/context-intelligence/capabilities.js +2 -1
  124. package/src/context-intelligence/cli.js +2 -0
  125. package/src/context-intelligence/config.js +3 -1
  126. package/src/context-intelligence/controls.js +49 -1
  127. package/src/context-intelligence/counterexample.js +200 -0
  128. package/src/context-intelligence/cross-route.js +111 -0
  129. package/src/context-intelligence/findings.js +166 -0
  130. package/src/context-intelligence/invariants.js +197 -0
  131. package/src/context-intelligence/queue.js +6 -1
  132. package/src/context-intelligence/reachability.js +2 -2
  133. package/src/context-intelligence/review.js +68 -4
  134. package/src/dataflow/access-paths.js +11 -0
  135. package/src/dataflow/analysis-limits.js +66 -0
  136. package/src/dataflow/engine.js +282 -24
  137. package/src/dataflow/service-edges.js +174 -0
  138. package/src/dataflow/summaries.js +7 -1
  139. package/src/discovery/judge.js +2 -1
  140. package/src/engine.js +82 -14
  141. package/src/fix/apply-fix-service.js +55 -8
  142. package/src/fix/batch-journal.js +84 -0
  143. package/src/fix/exploit-replay.js +51 -0
  144. package/src/fix/footprint.js +65 -0
  145. package/src/fix/patch-policy.js +49 -0
  146. package/src/fix/secret-persistence.js +39 -0
  147. package/src/fix/test-commands.js +84 -0
  148. package/src/fix/verification-adapters.js +97 -0
  149. package/src/fix/verification-record.js +243 -0
  150. package/src/fix/verify-patch.js +89 -0
  151. package/src/ir/CLAUDE.md +5 -0
  152. package/src/ir/callgraph.js +23 -1
  153. package/src/ir/module-resolver.js +315 -0
  154. package/src/ir/parser-js.js +21 -2
  155. package/src/ir/semantic-graph.js +245 -0
  156. package/src/language/haskell-findings.js +2 -0
  157. package/src/language/haskell-guards.js +1 -1
  158. package/src/language/haskell-ir.js +3 -1
  159. package/src/language/haskell-models.js +94 -0
  160. package/src/language/haskell-security-rules.js +20 -0
  161. package/src/language/nixos-hardening.js +72 -0
  162. package/src/lineage/build-attested-correlation.js +63 -0
  163. package/src/lineage/deployment-identity.js +76 -0
  164. package/src/mcp/tools.js +13 -34
  165. package/src/pipeline/canonical-output.js +64 -0
  166. package/src/pipeline/detector-runner.js +7 -0
  167. package/src/pipeline/scan-health.js +14 -0
  168. package/src/pipeline/supervised-analyzers.js +125 -0
  169. package/src/pipeline/supervised-child.js +17 -0
  170. package/src/pipeline/telemetry.js +79 -0
  171. package/src/posture/accuracy-scorecard.js +98 -9
  172. package/src/posture/artifact-registry.js +5 -0
  173. package/src/posture/assurance/evidence-registry.js +215 -0
  174. package/src/posture/assurance/release-package-verify.js +135 -0
  175. package/src/posture/assurance/release-package.js +118 -0
  176. package/src/posture/assurance/release-verdict.js +94 -0
  177. package/src/posture/assurance/waiver.js +200 -0
  178. package/src/posture/compliance-truth.js +100 -0
  179. package/src/posture/evaluation/scan-child.js +3 -1
  180. package/src/posture/evaluation/why-missed.js +3 -1
  181. package/src/posture/exploitability-evidence.js +141 -0
  182. package/src/posture/fix-verify.js +4 -2
  183. package/src/posture/fleet/exposure-ranking.js +115 -0
  184. package/src/posture/fleet/service-chain-graph.js +150 -0
  185. package/src/posture/graph-reconciliation.js +154 -0
  186. package/src/posture/provenance/coverage.js +103 -0
  187. package/src/posture/provenance/git-evidence.js +84 -0
  188. package/src/posture/provenance/origin-resolver.js +7 -1
  189. package/src/posture/provenance/predicate-replay.js +14 -4
  190. package/src/posture/runtime-correlation.js +61 -15
  191. package/src/posture/runtime-explanation.js +116 -0
  192. package/src/posture/runtime-trace-ingest.js +194 -0
  193. package/src/posture/scan-checkpoint.js +38 -10
  194. package/src/posture/waf-ingest.js +7 -2
  195. package/src/report/index.js +4 -0
  196. package/src/report/oscal.js +38 -0
  197. package/src/report/perspectives.js +150 -0
  198. package/src/sast/_comment-strip.js +8 -1
  199. package/src/sast/ruby.js +26 -0
  200. package/src/sast/zip-slip.js +79 -0
  201. package/src/util/string-memo.js +38 -0
@@ -761,7 +761,9 @@ class FnLowerer {
761
761
  // ── statements / control flow ──────────────────────────────────────────────
762
762
  addCallNode(expr, ln) {
763
763
  if (!expr) return;
764
- if (expr.kind === 'call') this.cfg.add('call', ln, { callee: expr.callee, args: expr.args, hs: expr.hs });
764
+ // The call expression carries the line it was written on. The context that finally executes it (the last statement of a `do` handed to `get "/r" $`) is on an
765
+ // earlier line, and reporting that line would place the finding on the start of the whole handler instead of on the sink.
766
+ if (expr.kind === 'call') this.cfg.add('call', Number.isInteger(expr.line) && expr.line >= 1 ? expr.line : ln, { callee: expr.callee, args: expr.args, hs: expr.hs });
765
767
  else if (expr.kind !== 'literal' && expr.kind !== 'ident') this.cfg.add('assign', ln, { target: `$e${this.tmp++}`, source: expr, hs: { demand: 'executed' } });
766
768
  }
767
769
 
@@ -49,6 +49,28 @@ export const HS_PACKAGES = Object.freeze({
49
49
  random: { modules: ['System.Random'], tested: ['1.2'] },
50
50
  hxt: { modules: ['Text.XML.HXT.Core'], tested: ['9.3'] },
51
51
  bytestring: { modules: ['Data.ByteString', 'Data.ByteString.Char8', 'Data.ByteString.Lazy'], tested: ['0.11', '0.12'] },
52
+ // Written from each library's documented API and checked only against the development cases in test/haskell/haskell-models-extended.test.js, which are
53
+ // plain source scans: no GHC build against these packages was run, so `tested` is empty and `modelStatus` reports every version as untested.
54
+ process_legacy: { modules: ['System.Cmd'], tested: [] },
55
+ turtle: { modules: ['Turtle'], tested: [] },
56
+ shelly: { modules: ['Shelly', 'Shelly.Lifted'], tested: [] },
57
+ hint: { modules: ['Language.Haskell.Interpreter'], tested: [] },
58
+ HDBC: { modules: ['Database.HDBC'], tested: [] },
59
+ 'direct-sqlite': { modules: ['Database.SQLite3'], tested: [] },
60
+ 'postgresql-libpq': { modules: ['Database.PostgreSQL.LibPQ'], tested: [] },
61
+ 'persistent-backends': { modules: ['Database.Persist.Postgresql', 'Database.Persist.Sqlite', 'Database.Persist.MySQL', 'Database.Persist'], tested: [] },
62
+ req: { modules: ['Network.HTTP.Req'], tested: [] },
63
+ HTTP: { modules: ['Network.HTTP'], tested: [] },
64
+ snap: { modules: ['Snap.Core', 'Snap'], tested: [] },
65
+ Spock: { modules: ['Web.Spock', 'Web.Spock.Core'], tested: [] },
66
+ happstack: { modules: ['Happstack.Server', 'Happstack.Server.Internal.Types'], tested: [] },
67
+ network: { modules: ['Network.Socket.ByteString', 'Network.Socket.ByteString.Lazy'], tested: [] },
68
+ unix: { modules: ['System.Posix.Files'], tested: [] },
69
+ jwt: { modules: ['Web.JWT'], tested: [] },
70
+ 'servant-auth-server': { modules: ['Servant.Auth.Server'], tested: [] },
71
+ 'clientsession': { modules: ['Web.ClientSession'], tested: [] },
72
+ connection: { modules: ['Network.Connection'], tested: [] },
73
+ HsOpenSSL: { modules: ['OpenSSL.Session'], tested: [] },
52
74
  });
53
75
 
54
76
  // ── sources ─────────────────────────────────────────────────────────────────
@@ -85,6 +107,30 @@ src('Yesod', ['lookupGetParam', 'lookupGetParams', 'lookupPostParam', 'lookupPos
85
107
  // HTTP responses are untrusted data too when they come back from a remote service the app calls
86
108
  src('Network.HTTP.Simple', ['getResponseBody'], 'network', 'remote HTTP response', { package: 'http-conduit' });
87
109
  src('Network.HTTP.Client', ['responseBody'], 'network', 'remote HTTP response', { package: 'http-client' });
110
+ // More file readers, and more web frameworks. Each reads a value the client (or the file's author) controls.
111
+ src('Data.ByteString.Char8', ['readFile'], 'file-read', 'file content', { package: 'bytestring' });
112
+ src('Data.ByteString.Lazy.Char8', ['readFile'], 'file-read', 'file content', { package: 'bytestring' });
113
+ src('Data.Text.Lazy.IO', ['readFile'], 'file-read', 'file content', { package: 'text' });
114
+ src('Network.Socket.ByteString', ['recv', 'recvFrom'], 'network', 'bytes read from a socket', { package: 'network' });
115
+ src('Network.Socket.ByteString.Lazy', ['recv', 'getContents'], 'network', 'bytes read from a socket', { package: 'network' });
116
+ src('Network.HTTP', ['getResponseBody'], 'network', 'remote HTTP response', { package: 'HTTP' });
117
+ // Snap
118
+ src('Snap.Core', ['getParam', 'getPostParam', 'getQueryParam', 'getParams', 'getPostParams', 'getQueryParams'], 'url-param', 'snap request parameter', { package: 'snap', framework: 'snap' });
119
+ src('Snap.Core', ['readRequestBody', 'getRequestBody'], 'http-body', 'snap request body', { package: 'snap', framework: 'snap' });
120
+ src('Snap', ['getParam', 'getPostParam', 'getQueryParam', 'getParams', 'getPostParams', 'getQueryParams'], 'url-param', 'snap request parameter', { package: 'snap', framework: 'snap' });
121
+ // Spock
122
+ src('Web.Spock', ['param', "param'", 'params'], 'url-param', 'spock request parameter', { package: 'Spock', framework: 'spock' });
123
+ src('Web.Spock', ['header', 'cookie', 'cookies'], 'header', 'spock request header or cookie', { package: 'Spock', framework: 'spock' });
124
+ src('Web.Spock', ['body', 'jsonBody', "jsonBody'", 'files'], 'http-body', 'spock request body', { package: 'Spock', framework: 'spock' });
125
+ src('Web.Spock.Core', ['param', "param'", 'params'], 'url-param', 'spock request parameter', { package: 'Spock', framework: 'spock' });
126
+ // Happstack
127
+ src('Happstack.Server', ['look', 'lookText', "lookText'", 'lookBS', 'lookRead', 'lookPairs', 'lookPairsBS', 'lookCookieValue'], 'url-param', 'happstack request input', { package: 'happstack', framework: 'happstack' });
128
+ // Yesod: the whole request value and its body
129
+ src('Yesod.Core', ['getRequest', 'rawRequestBody', 'runRequestBody', 'lookupFile', 'lookupFiles'], 'http-body', 'yesod request', { package: 'yesod', framework: 'yesod' });
130
+ src('Yesod', ['getRequest', 'rawRequestBody', 'runRequestBody', 'lookupFile', 'lookupFiles'], 'http-body', 'yesod request', { package: 'yesod', framework: 'yesod' });
131
+ // Scotty: the underlying WAI request
132
+ src('Web.Scotty', ['request'], 'http-body', 'scotty WAI request', { package: 'scotty', framework: 'scotty' });
133
+ src('Web.Scotty.Trans', ['request'], 'http-body', 'scotty WAI request', { package: 'scotty', framework: 'scotty' });
88
134
 
89
135
  // ── sinks ───────────────────────────────────────────────────────────────────
90
136
  // family: the taint family the existing engine already uses (cmd | sql | path | url | xss)
@@ -161,6 +207,52 @@ sink('Prelude', ['putStrLn', 'putStr'], XSS_TEXT);
161
207
  sink('System.IO', ['putStrLn', 'putStr'], XSS_TEXT);
162
208
  sink('Data.Text.IO', ['putStrLn', 'putStr'], { ...XSS_TEXT, package: 'text' });
163
209
 
210
+ // ── more sinks (written from the libraries' documented behaviour) ───────────────────────────────────────────────────────
211
+ // CWE-78. The legacy System.Cmd module, Turtle and Shelly. A shell string is parsed by /bin/sh; a program name is not, but a tainted one is arbitrary execution.
212
+ sink('System.Cmd', ['system'], { family: 'cmd', cwe: 'CWE-78', severity: 'critical', argIndex: 0, shell: true, package: 'process_legacy', vuln: 'OS Command Injection (shell string)', remediation: 'Do not build a shell command from input. Use `proc`/`callProcess` with a fixed program and pass each value as its own argument.' });
213
+ sink('System.Cmd', ['rawSystem'], { family: 'cmd', cwe: 'CWE-78', severity: 'high', argIndex: 0, shell: false, package: 'process_legacy', vuln: 'OS Command Injection (program name)', remediation: 'Use a fixed program name; never take it from input.' });
214
+ sink('System.Cmd', ['rawSystem'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'process_legacy', vuln: 'Argument Injection (process arguments)', remediation: 'Validate each argument against an allow-list, and put `--` before values that could start with a dash.' });
215
+ sink('Turtle', ['shell', 'inshell', 'shells', 'inshells'], { family: 'cmd', cwe: 'CWE-78', severity: 'critical', argIndex: 0, shell: true, package: 'turtle', vuln: 'OS Command Injection (Turtle shell command)', remediation: 'Use `proc`/`procs` with a fixed program and a list of arguments, not a shell string built from input.' });
216
+ sink('Turtle', ['proc', 'procs', 'inproc', 'inprocs'], { family: 'cmd', cwe: 'CWE-78', severity: 'high', argIndex: 0, shell: false, package: 'turtle', vuln: 'OS Command Injection (program name)', remediation: 'Use a fixed program name; never take it from input.' });
217
+ sink('Turtle', ['proc', 'procs', 'inproc', 'inprocs'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'turtle', vuln: 'Argument Injection (process arguments)', remediation: 'Validate each argument against an allow-list, and put `--` before values that could start with a dash.' });
218
+ for (const mod of ['Shelly', 'Shelly.Lifted']) {
219
+ sink(mod, ['run', 'run_', 'runFoldLines', 'command', 'command_'], { family: 'cmd', cwe: 'CWE-78', severity: 'high', argIndex: 0, shell: false, package: 'shelly', vuln: 'OS Command Injection (program name)', remediation: 'Use a fixed program name; never take it from input.' });
220
+ sink(mod, ['run', 'run_', 'runFoldLines'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'shelly', vuln: 'Argument Injection (process arguments)', remediation: 'Validate each argument against an allow-list, and put `--` before values that could start with a dash.' });
221
+ sink(mod, ['bash', 'bash_', 'bashPipeFail'], { family: 'cmd', cwe: 'CWE-78', severity: 'critical', argIndex: 0, shell: true, package: 'shelly', vuln: 'OS Command Injection (bash script text)', remediation: 'Do not build a script from input. Call the program directly with `run` and a list of arguments.' });
222
+ }
223
+
224
+ // CWE-94. Evaluating Haskell source text at run time (the hint package) runs whatever code the text holds.
225
+ sink('Language.Haskell.Interpreter', ['interpret', 'eval', 'runStmt'], { family: 'code', cwe: 'CWE-94', severity: 'critical', argIndex: 0, package: 'hint', vuln: 'Code Injection (Haskell source evaluated at run time)', remediation: 'Do not evaluate text that came from a user. Parse the input into a closed data type and interpret that instead.' });
226
+
227
+ // CWE-89. HDBC, direct-sqlite, postgresql-libpq, and the persistent backends that re-export rawSql. The query TEXT is the sink; a `?` placeholder with bound values is the safe form.
228
+ sink('Database.HDBC', ['run', 'quickQuery', "quickQuery'", 'prepare', 'runRaw'], { ...SQL, argIndex: 1, package: 'HDBC', vuln: 'SQL Injection (HDBC query text)' });
229
+ sink('Database.SQLite3', ['exec', 'prepare'], { ...SQL, argIndex: 1, package: 'direct-sqlite', vuln: 'SQL Injection (SQLite statement text)' });
230
+ sink('Database.PostgreSQL.LibPQ', ['exec', 'execParams', 'prepare'], { ...SQL, argIndex: 1, package: 'postgresql-libpq', vuln: 'SQL Injection (libpq command text)' });
231
+ for (const mod of ['Database.Persist.Postgresql', 'Database.Persist.Sqlite', 'Database.Persist.MySQL']) sink(mod, ['rawSql', 'rawExecute', 'rawQuery'], { ...SQL, argIndex: 0, package: 'persistent-backends', vuln: 'SQL Injection (raw SQL text)' });
232
+
233
+ // CWE-918. Req builds a URL from typed pieces, so the HOST is the attacker-steered part; HTTP's request constructors take the URL text.
234
+ sink('Network.HTTP.Req', ['https', 'http'], { ...SSRF, argIndex: 0, package: 'req', vuln: 'Server-Side Request Forgery (request host)' });
235
+ sink('Network.HTTP', ['getRequest', 'postRequest', 'headRequest', 'simpleHTTP'], { ...SSRF, argIndex: 0, package: 'HTTP' });
236
+
237
+ // CWE-601. Redirecting to a location taken from the request sends the user to a site the attacker names.
238
+ const REDIRECT = { family: 'redirect', cwe: 'CWE-601', severity: 'medium', argIndex: 0, vuln: 'Open Redirect', remediation: 'Redirect only to a fixed path or to a value found in an allow-list; never to a request value as given.' };
239
+ sink('Web.Scotty', ['redirect'], { ...REDIRECT, package: 'scotty', framework: 'scotty' });
240
+ sink('Web.Scotty.Trans', ['redirect'], { ...REDIRECT, package: 'scotty', framework: 'scotty' });
241
+ sink('Yesod.Core', ['redirect'], { ...REDIRECT, package: 'yesod', framework: 'yesod' });
242
+ sink('Yesod.Core', ['redirectWith'], { ...REDIRECT, argIndex: 1, package: 'yesod', framework: 'yesod' });
243
+ sink('Yesod', ['redirect'], { ...REDIRECT, package: 'yesod', framework: 'yesod' });
244
+ sink('Snap.Core', ['redirect', "redirect'"], { ...REDIRECT, package: 'snap', framework: 'snap' });
245
+ sink('Snap', ['redirect', "redirect'"], { ...REDIRECT, package: 'snap', framework: 'snap' });
246
+ sink('Web.Spock', ['redirect'], { ...REDIRECT, package: 'Spock', framework: 'spock' });
247
+ sink('Happstack.Server', ['seeOther', 'tempRedirect', 'movedPermanently'], { ...REDIRECT, package: 'happstack', framework: 'happstack' });
248
+
249
+ // CWE-22. The same path operations through the other string types, and the POSIX file calls.
250
+ sink('Data.ByteString.Char8', ['readFile', 'writeFile', 'appendFile'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'bytestring', vuln: 'Path Traversal', remediation: 'Confirm the canonical path stays under an allowed directory before opening it.' });
251
+ sink('Data.ByteString.Lazy.Char8', ['readFile', 'writeFile', 'appendFile'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'bytestring', vuln: 'Path Traversal', remediation: 'Confirm the canonical path stays under an allowed directory before opening it.' });
252
+ sink('Data.Text.Lazy.IO', ['readFile', 'writeFile', 'appendFile'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'text', vuln: 'Path Traversal', remediation: 'Confirm the canonical path stays under an allowed directory before opening it.' });
253
+ sink('System.Posix.Files', ['removeLink', 'createSymbolicLink', 'setFileMode', 'setOwnerAndGroup', 'readSymbolicLink'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'unix', vuln: 'Path Traversal (file operation)', remediation: 'Confirm the canonical path stays under an allowed directory before the operation.' });
254
+ sink('System.Posix.Files', ['rename'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 'all', package: 'unix', vuln: 'Path Traversal (rename)', remediation: 'Confirm both canonical paths stay under an allowed directory.' });
255
+
164
256
  // ── sanitizers (context-specific; `appliesTo` is the family each one actually neutralises) ──
165
257
  const SAN = [];
166
258
  const san = (module, names, appliesTo, note, extra = {}) => { for (const e of E(module, names)) SAN.push({ ...e, appliesTo, note, ...extra }); };
@@ -219,6 +311,8 @@ export const HS_RULE_APIS = Object.freeze([
219
311
  ...E('Data.Text.IO', ['getContents', 'hGetContents']), ...E('Data.Text.Lazy.IO', ['getContents', 'hGetContents']),
220
312
  ...E('Data.Text', ['replicate', 'take']), ...E('Data.Vector', ['replicate']),
221
313
  ...E('Web.Cookie', ['defaultSetCookie']),
314
+ ...E('Web.JWT', ['hmacSecret', 'secret']), ...E('Servant.Auth.Server', ['fromSecret']), ...E('Web.ClientSession', ['initKey']),
315
+ ...E('Network.HTTP.Client.TLS', ['mkManagerSettings']), ...E('OpenSSL.Session', ['contextSetVerificationMode']),
222
316
  ...E('Text.XML.HXT.Core', ['withSubstDTDEntities']),
223
317
  ...E('System.IO.Unsafe', ['unsafePerformIO', 'unsafeInterleaveIO', 'unsafeDupablePerformIO']),
224
318
  ...E('Unsafe.Coerce', ['unsafeCoerce']),
@@ -33,6 +33,8 @@ export const RULES = Object.freeze({
33
33
  'hs-unbounded-input-read': { cwe: 'CWE-770', severity: 'medium', family: 'resource-exhaustion', vuln: 'Whole input read into memory without a size limit', remediation: 'Bound the read (`BL.take n`, `hGetSome`, a request-size limit) before the data is consumed.' },
34
34
  'hs-unbounded-allocation': { cwe: 'CWE-770', severity: 'medium', family: 'resource-exhaustion', vuln: 'Allocation sized by parsed caller text without an upper bound', remediation: 'Clamp the size (`min limit n`) before allocating.' },
35
35
  'hs-partial-function-on-input': { cwe: 'CWE-248', severity: 'low', family: 'unhandled-exception', vuln: 'Partial function applied to caller-supplied text', remediation: 'Use the total variant (`readMaybe`, `listToMaybe`, pattern matching) and handle the failure.' },
36
+ 'hs-tls-validation-disabled': { cwe: 'CWE-295', severity: 'high', family: 'tls-validation', vuln: 'TLS certificate validation is disabled', remediation: 'Do not disable certificate validation. Use the default settings (`tlsManagerSettings`, `TLSSettingsSimple False False False`, `VerifyPeer`) and, for a private certificate authority, add that authority to the trust store instead.' },
37
+ 'hs-hardcoded-secret': { cwe: 'CWE-798', severity: 'high', family: 'hardcoded-credentials', vuln: 'Signing or encryption secret written in the source', remediation: 'Read the secret from the environment or a secret store at start-up, and rotate the one that was committed: it is now in the repository history.' },
36
38
  'hs-unsafe-io-boundary': { cwe: 'CWE-676', severity: 'low', family: 'unsafe-boundary', vuln: 'Unsafe IO / coercion boundary', remediation: 'Confine `unsafePerformIO`/`unsafeCoerce` to audited, well-tested wrappers.' },
37
39
  });
38
40
 
@@ -56,6 +58,11 @@ const PBKDF2_CALLS = new Set(['Crypto.KDF.PBKDF2.generate', 'Crypto.KDF.PBKDF2.f
56
58
  const STRONG_KDF = /^(?:Crypto\.KDF\.BCrypt\.|Crypto\.Argon2\.|Crypto\.Scrypt\.|Crypto\.KDF\.Scrypt\.|Crypto\.PasswordStore\.|Crypto\.BCrypt\.)/;
57
59
  const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'System.IO.Unsafe.unsafeInterleaveIO': 'unsafeInterleaveIO', 'System.IO.Unsafe.unsafeDupablePerformIO': 'unsafeDupablePerformIO', 'Unsafe.Coerce.unsafeCoerce': 'unsafeCoerce' };
58
60
 
61
+ // TLS: the first field of TLSSettingsSimple (Network.Connection) is settingDisableCertificateValidation, and `VerifyNone` (OpenSSL.Session) skips peer verification.
62
+ // The IR keeps a constructor's name in `hs.con` but not the module it came from, so a constructor counts only in a file that imports the module defining it.
63
+ // Calls whose argument is a key or signing secret.
64
+ const SECRET_KEY_CALLS = new Set(['Web.JWT.hmacSecret', 'Web.JWT.secret', 'Servant.Auth.Server.fromSecret', 'Web.ClientSession.initKey']);
65
+ const isTrueLit = (e) => !!e && ((e.kind === 'literal' && /^true$/i.test(String(e.value))) || (e.kind === 'ident' && /(?:^|\.)True$/.test(e.name || '')));
59
66
  const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
60
67
  const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|salt|apikey|resetcode|passcode|verification)/i;
61
68
  // Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
@@ -215,6 +222,16 @@ export function analyzeHaskellRules(files, opts = {}) {
215
222
  }
216
223
  continue;
217
224
  }
225
+ // TLS certificate validation switched off, by constructor
226
+ if (e.hs && e.hs.con) {
227
+ const importsFrom = (mods) => (fir.imports || []).some((im) => mods.includes(im.module));
228
+ if (e.hs.con === 'TLSSettingsSimple' && importsFrom(['Network.Connection', 'Network.Connection.Types'])) {
229
+ const positional = e.kind === 'array' && isTrueLit(e.elements[0]);
230
+ const named = e.kind === 'object' && (e.props || []).some((p) => p && p.key === 'settingDisableCertificateValidation' && isTrueLit(p.value));
231
+ if (positional || named) emit('hs-tls-validation-disabled', file, e.line || node.line, { detail: 'TLSSettingsSimple with settingDisableCertificateValidation = True', evidence: { setting: 'settingDisableCertificateValidation' } });
232
+ }
233
+ if (e.hs.con === 'VerifyNone' && e.kind === 'literal' && importsFrom(['OpenSSL.Session'])) emit('hs-tls-validation-disabled', file, e.line || node.line, { detail: 'OpenSSL verification mode VerifyNone', evidence: { mode: 'VerifyNone' } });
234
+ }
218
235
  // `xs !! n` raises on a short list: a partial function, written as an operator, applied to caller-supplied text
219
236
  if (e.kind === 'binary' && e.op === '!!' && e.left && mentionsText(e.left)) {
220
237
  emit('hs-partial-function-on-input', file, e.line || node.line, { detail: '!! on a caller-supplied list', evidence: { partial: '!!' } });
@@ -225,6 +242,9 @@ export function analyzeHaskellRules(files, opts = {}) {
225
242
  const line = e.line || node.line;
226
243
  const args = e.args || [];
227
244
 
245
+ // TLS certificate validation switched off
246
+ // a signing/encryption secret that is a constant in the source
247
+ if (SECRET_KEY_CALLS.has(c) && args[0] && isLiteralLike(args[0]) && !(args[0].kind === 'literal' && String(args[0].value).length < 8)) emit('hs-hardcoded-secret', file, line, { detail: `${c} given a constant`, confidence: 0.85, evidence: { call: c } });
228
248
  // weak hash
229
249
  if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value))) {
230
250
  const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
@@ -54,6 +54,11 @@ export const HARDENING_RULES = Object.freeze({
54
54
  'tls-not-enforced': { family: 'tls-runtime', cwe: 'CWE-319', severity: 'medium', ruleVersion: 1, vuln: 'Virtual host explicitly serves cleartext HTTP (forceSSL = false)', why: 'forceSSL = false is a deliberate choice to keep the plain-HTTP listener open for the whole host, so credentials and session data cross the network unencrypted.', fix: 'Set `forceSSL = true` (with `enableACME = true` or a certificate) so HTTP is redirected to HTTPS.' },
55
55
  'container-privileged': { family: 'container-declaration', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'OCI container is declared privileged or with a dangerous mount', why: '--privileged, SYS_ADMIN/ALL capabilities and the container runtime socket give the container control of the host. Image layers are NOT scanned.', fix: 'Remove --privileged and the runtime socket mount; add only the specific capability the workload needs.' },
56
56
  'container-host-network': { family: 'container-declaration', cwe: 'CWE-668', severity: 'medium', ruleVersion: 1, vuln: 'OCI container shares the host network namespace', why: 'Host networking removes network isolation, so the container can reach and bind host-local services. Image layers are NOT scanned.', fix: 'Use the default bridge and publish only the needed ports on a specific address.' },
57
+ 'user-root-equivalent-group': { family: 'privilege-escalation', cwe: 'CWE-269', severity: 'medium', ruleVersion: 1, vuln: 'A user is a member of a group that is equivalent to root', why: 'Membership of docker or lxd lets the user start a container with the host filesystem mounted, and membership of disk gives raw read and write access to the block devices. Each is root on the machine, without sudo and without a password prompt.', fix: 'Remove the group. Use rootless containers (virtualisation.docker.rootless, podman) or run the tool through sudo with a narrow rule.' },
58
+ 'user-uid-zero': { family: 'privilege-escalation', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'A user other than root has user id 0', why: 'User id 0 is root: the kernel does not look at the name. A second account with uid 0 is a root login that audit logs, sudo rules and per-user controls do not treat as root.', fix: 'Remove the uid assignment so the account gets a normal id, or use sudo for the cases that need root.' },
59
+ 'getty-autologin': { family: 'login-access', cwe: 'CWE-306', severity: 'high', ruleVersion: 1, vuln: 'The console logs a user in without a password', why: 'services.getty.autologinUser starts a session on every virtual console with no authentication. For root that is a root shell for anyone with the keyboard, a serial line or a hypervisor console.', fix: 'Remove services.getty.autologinUser. Keep autologin to throwaway virtual machines and tests.' },
60
+ 'display-autologin': { family: 'login-access', cwe: 'CWE-306', severity: 'low', ruleVersion: 1, vuln: 'The display manager logs a user in without a password', why: 'Automatic login skips authentication at the graphical login, so anyone who can power the machine on gets the session. Disk encryption is the only remaining barrier.', fix: 'Disable autoLogin, or keep it only where physical access is controlled and the disk is encrypted.' },
61
+ 'pam-null-password': { family: 'login-access', cwe: 'CWE-258', severity: 'high', ruleVersion: 1, vuln: 'A PAM service accepts accounts with an empty password', why: 'allowNullPassword lets an account with no password log in through this service with an empty answer, which turns every passwordless account into an open login.', fix: 'Remove allowNullPassword (it defaults to false) and give every account a password hash or a key.' },
57
62
  });
58
63
 
59
64
  /** Which rules explain each privileged entry-point kind (reconciliation join). */
@@ -368,6 +373,41 @@ export function analyzeNixosHardening(opts, extra = {}) {
368
373
  }
369
374
  }
370
375
 
376
+ // ── accounts and console login ──
377
+ // Root-equivalent groups: each of these gives the member control of the host without sudo.
378
+ const ROOT_EQUIVALENT_GROUPS = new Map([['docker', 'start a container with the host root mounted'], ['lxd', 'start a privileged container with the host root mounted'], ['disk', 'read and write the raw block devices']]);
379
+ for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^users\.users\.[^.]+\.extraGroups$/.test(x.path))) {
380
+ const name = o.path.split('.')[2];
381
+ if (name === 'root') continue;
382
+ const v = view(note(o, 'accounts'));
383
+ const items = v.state === 'known' ? asList(v.value) : v.state === 'conditional' ? asList(v.definiteItems) : [];
384
+ const bad = items.filter((g) => typeof g === 'string' && ROOT_EQUIVALENT_GROUPS.has(g));
385
+ if (bad.length) addFinding('user-root-equivalent-group', { anchor: anchorOf(o), scope: `users.users.${name}`, state: v.state === 'conditional' ? 'conditional' : 'active', uncertain: !v.definite, evidence: [evidenceOf(o)], extra: { groups: bad, effect: bad.map((g) => `${g}: ${ROOT_EQUIVALENT_GROUPS.get(g)}`) } });
386
+ }
387
+ for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^users\.users\.[^.]+\.uid$/.test(x.path))) {
388
+ const name = o.path.split('.')[2];
389
+ if (name === 'root') continue;
390
+ const j = judge(note(o, 'accounts'), (x) => x === 0);
391
+ if (j === 'yes' || j === 'conditional') addFinding('user-uid-zero', { anchor: anchorOf(o), scope: `users.users.${name}`, state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(o).definite, evidence: [evidenceOf(o)] });
392
+ }
393
+ const gettyRes = note(nixos('services.getty.autologinUser'), 'login');
394
+ const gettyV = view(gettyRes);
395
+ if (gettyV.state === 'known' && typeof gettyV.value === 'string' && gettyV.value) {
396
+ addFinding('getty-autologin', { severity: gettyV.value === 'root' ? 'high' : 'low', anchor: anchorOf(gettyRes), scope: 'services.getty.autologinUser', state: 'active', uncertain: !gettyV.definite, evidence: [evidenceOf(gettyRes)], extra: { user: gettyV.value } });
397
+ }
398
+ for (const base of ['services.displayManager.autoLogin', 'services.xserver.displayManager.autoLogin']) {
399
+ const en = note(nixos(`${base}.enable`), 'login');
400
+ const j = judge(en, (x) => x === true);
401
+ if (j === 'yes' || j === 'conditional') {
402
+ const userV = view(nixos(`${base}.user`));
403
+ addFinding('display-autologin', { severity: userV.state === 'known' && userV.value === 'root' ? 'high' : 'low', anchor: anchorOf(en), scope: base, state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(en).definite, evidence: [evidenceOf(en)], extra: userV.state === 'known' ? { user: userV.value } : {} });
404
+ }
405
+ }
406
+ for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^security\.pam\.services\.[^.]+\.allowNullPassword$/.test(x.path))) {
407
+ const j = judge(note(o, 'login'), (x) => x === true);
408
+ if (j === 'yes' || j === 'conditional') addFinding('pam-null-password', { anchor: anchorOf(o), scope: o.path.replace(/\.allowNullPassword$/, ''), state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(o).definite, evidence: [evidenceOf(o)] });
409
+ }
410
+
371
411
  // ── databases / listeners ──
372
412
  if (hasDefs('services.postgresql.')) {
373
413
  const enableRes = nixos('services.postgresql.enable');
@@ -398,6 +438,38 @@ export function analyzeNixosHardening(opts, extra = {}) {
398
438
  inventory.push({ kind: 'declared-service', name: 'nginx', state, listening: state === 'active' ? 'unknown' : state === 'conditional' ? 'unknown' : 'no', ports: [], ingress: 'unknown', reachability: { internet: 'unknown', reason: 'listen addresses are generated by the module and external topology is not modelled' }, evidence: [evidenceOf(enableRes)] });
399
439
  }
400
440
 
441
+ // ── other services that choose their bind address ──
442
+ // The same decision postgresql makes above. Only an EXPLICIT wildcard (or, for redis, an explicit null, which the module defines as "every interface") is reported:
443
+ // an unset option keeps the module's own default, which the catalog does not record for these services and which is never guessed.
444
+ const WILDCARD = (x, nullIsAll) => (x === null ? nullIsAll === true : typeof x === 'string' && x.split(/[\s,]+/).filter(Boolean).some((a) => a === '*' || a === '0.0.0.0' || a === '::' || a === '[::]'));
445
+ const LISTENERS = [
446
+ { name: 'redis', pattern: /^services\.redis\.servers\.([^.]+)\.bind$/, enable: (m) => `services.redis.servers.${m[1]}.enable`, portOpt: (m) => `services.redis.servers.${m[1]}.port`, port: 6379, nullIsAll: true, scope: (m) => `services.redis.servers.${m[1]}` },
447
+ { name: 'mysql', pattern: /^services\.mysql\.settings\.mysqld\.(?:bind-address|bind_address)$/, enable: () => 'services.mysql.enable', port: 3306, scope: () => 'services.mysql' },
448
+ { name: 'mongodb', pattern: /^services\.mongodb\.bind_ip$/, enable: () => 'services.mongodb.enable', port: 27017, scope: () => 'services.mongodb' },
449
+ { name: 'memcached', pattern: /^services\.memcached\.listen$/, enable: () => 'services.memcached.enable', portOpt: () => 'services.memcached.port', port: 11211, scope: () => 'services.memcached' },
450
+ { name: 'elasticsearch', pattern: /^services\.elasticsearch\.listenAddress$/, enable: () => 'services.elasticsearch.enable', portOpt: () => 'services.elasticsearch.port', port: 9200, scope: () => 'services.elasticsearch' },
451
+ { name: 'grafana', pattern: /^services\.grafana\.settings\.server\.http_addr$/, enable: () => 'services.grafana.enable', port: 3000, scope: () => 'services.grafana' },
452
+ ];
453
+ for (const L of LISTENERS) {
454
+ for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && L.pattern.test(x.path))) {
455
+ const m = L.pattern.exec(o.path);
456
+ const listenRes = note(o, 'listener');
457
+ const wild = judge(listenRes, (x) => WILDCARD(x, L.nullIsAll));
458
+ if (wild !== 'yes' && wild !== 'conditional') continue;
459
+ const enableRes = note(nixos(L.enable(m)), 'listener');
460
+ const state = stateOf(enableRes);
461
+ if (state !== 'active' && state !== 'conditional') continue;
462
+ let port = L.port;
463
+ if (L.portOpt) { const pv = view(nixos(L.portOpt(m))); if (pv.state === 'known' && Number.isInteger(pv.value) && pv.value > 0) port = pv.value; }
464
+ const ingress = ingressFor(port);
465
+ const scope = L.scope(m);
466
+ inventory.push({ kind: 'declared-service', name: L.name, state, listening: 'yes', ports: [port], ingress, reachability: { internet: 'unknown', reason: 'external network topology is not part of the configuration' }, evidence: [evidenceOf(enableRes), evidenceOf(listenRes)] });
467
+ const st = state === 'conditional' || wild === 'conditional' ? 'conditional' : 'active';
468
+ const restricted = ingress === 'firewall-closed' || ingress === 'restricted-interface';
469
+ addFinding('listener-all-interfaces', { severity: restricted ? 'low' : ingress === 'open-in-firewall' || ingress === 'firewall-disabled' ? 'high' : 'medium', anchor: anchorOf(listenRes), scope, state: st, uncertain: !view(listenRes).definite, evidence: [evidenceOf(listenRes), evidenceOf(enableRes)], extra: { exposure: { enabled: true, listening: 'yes', ingress, reachable: 'unknown' } }, mitigations: restricted ? [{ kind: 'restrictive-ingress', ingress }] : [] });
470
+ }
471
+ }
472
+
401
473
  // ── TLS material and enforcement on nginx virtual hosts ──
402
474
  if (hasDefs('services.nginx.')) {
403
475
  const nginxState = stateOf(nixos('services.nginx.enable'));
@@ -0,0 +1,63 @@
1
+ // Runtime correlation that knows which build it is talking about (E5-02).
2
+ //
3
+ // `correlateObservations` (observation-correlation.js) is pinned, byte for byte, by its own tests: it answers "was this flow seen in the window" and
4
+ // nothing about WHICH build was seen. This wrapper adds that, without changing it. A caller that names the static snapshot's identity gets back the
5
+ // same correlation, computed over only the observations that could be about that code, plus a statement per flow of whether the evidence is tied to
6
+ // the snapshot's build:
7
+ //
8
+ // corroborated every contributing observation matched the snapshot's identity (deployment-identity.js: matched)
9
+ // unattested at least one contributing observation carried no comparable identity: seen at runtime, but not shown to be this code
10
+ // none no contributing observation
11
+ //
12
+ // Observations from a DIFFERENT build are excluded and listed under `buildMismatchedObservationIds`: they are not evidence about this code at all.
13
+ // A caller that names no snapshot identity gets the original result unchanged.
14
+ import { execFileSync } from 'node:child_process';
15
+ import { correlateObservations } from './observation-correlation.js';
16
+ import { canCorroborate, linkObservationToSnapshot, normalizeIdentity, observationIdentity } from './deployment-identity.js';
17
+
18
+ const LAYER_RUNTIME_OBSERVED = 'runtime_observed';
19
+
20
+ /** The identity of the source snapshot at `root`: the git HEAD, whether the work tree is dirty, and anything the caller adds (image digest, build hash, environment). */
21
+ export function snapshotIdentityOf(root, extra = {}) {
22
+ let commit = null; let dirty = false;
23
+ try {
24
+ commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
25
+ dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).trim().length > 0;
26
+ } catch { commit = null; }
27
+ return { ...normalizeIdentity({ commit, dirty, ...extra }) };
28
+ }
29
+
30
+ /**
31
+ * @param {object} graph
32
+ * @param {object[]|null|undefined} observations as for correlateObservations
33
+ * @param {object} opts correlateObservations' options plus
34
+ * snapshotIdentity {commit, buildHash, imageDigest, sbomRef, environment, dirty} of the static snapshot. Without it the original result is returned unchanged.
35
+ * observationBuilds {[observationId]: {commit, buildHash, imageDigest, sbomRef}} identity the importer recorded for an observation
36
+ */
37
+ export function correlateWithBuild(graph, observations, opts = {}) {
38
+ const snapshot = opts.snapshotIdentity && typeof opts.snapshotIdentity === 'object' ? normalizeIdentity(opts.snapshotIdentity) : null;
39
+ if (!snapshot || !Array.isArray(observations)) return correlateObservations(graph, observations, opts);
40
+ const builds = opts.observationBuilds && typeof opts.observationBuilds === 'object' ? opts.observationBuilds : {};
41
+ const links = new Map(); const kept = []; const mismatched = [];
42
+ for (const obs of observations) {
43
+ const id = obs && typeof obs.id === 'string' ? obs.id : null;
44
+ const claimed = id && builds[id] ? { ...observationIdentity(obs), ...normalizeIdentity({ ...builds[id], environment: obs.environment }) } : observationIdentity(obs);
45
+ const link = linkObservationToSnapshot(claimed, snapshot);
46
+ if (id) links.set(id, link);
47
+ if (link.state === 'mismatched') { if (id) mismatched.push(id); continue; }
48
+ kept.push(obs);
49
+ }
50
+ const base = correlateObservations(graph, kept, opts);
51
+ const byFlow = {};
52
+ for (const [fid, entry] of Object.entries(base.byFlow || {})) {
53
+ const ids = entry.observationIds || [];
54
+ const corroboration = entry.layer !== LAYER_RUNTIME_OBSERVED || !ids.length ? 'none' : ids.every((i) => canCorroborate(links.get(i))) ? 'corroborated' : 'unattested';
55
+ byFlow[fid] = { ...entry, corroboration };
56
+ }
57
+ return {
58
+ ...base, byFlow,
59
+ buildMismatchedObservationIds: [...new Set(mismatched)].sort(),
60
+ buildLink: { requested: true, snapshot: { commit: snapshot.commit, buildHash: snapshot.buildHash, imageDigest: snapshot.imageDigest, environment: snapshot.environment, dirty: snapshot.dirty } },
61
+ limitations: [...(base.limitations || []), 'Observations from a different build were excluded; observations with no comparable build identity are kept but marked unattested, never corroborated.'],
62
+ };
63
+ }
@@ -0,0 +1,76 @@
1
+ // Tying runtime observations to the code that was deployed (E5-02).
2
+ //
3
+ // A runtime observation is evidence about ONE running build. A static finding is evidence about ONE source snapshot. The two are the same subject only
4
+ // when the build is known to have been made from that snapshot. Without that tie, "this route received traffic" says nothing about whether the code
5
+ // that received it contained the finding, and calling it corroboration would be a false claim.
6
+ //
7
+ // WHAT TIES THEM. Four identity fields, compared when both sides have them:
8
+ // commit the git commit (a full sha, or a prefix of at least 12 hex characters)
9
+ // buildHash a build or reproducibility hash
10
+ // imageDigest the container image digest (sha256:...)
11
+ // sbomRef an SBOM or artifact reference
12
+ // plus the environment the observation came from, which must equal the environment the snapshot's deployment record names when both are present.
13
+ //
14
+ // THE RULES (each is a test)
15
+ // mismatched a field both sides carry DIFFERS, or the environments differ. One disagreement is enough, however many fields agree.
16
+ // matched at least one STRONG field (commit, buildHash or imageDigest) is present on both sides and equal, and nothing disagrees
17
+ // unknown no strong field is present on both sides: the observation carries no identity, the snapshot carries none, or they carry different kinds
18
+ // A snapshot with uncommitted changes cannot be tied to a commit: a commit match is downgraded to `unknown` with that reason, because the code that was
19
+ // scanned is not the code the commit names.
20
+ // Only `matched` may be labelled corroborating. `unknown` is unattested evidence; `mismatched` is excluded and counted.
21
+ export const STRONG_FIELDS = Object.freeze(['commit', 'buildHash', 'imageDigest']);
22
+ export const IDENTITY_FIELDS = Object.freeze(['commit', 'buildHash', 'imageDigest', 'sbomRef']);
23
+ const ATTR = Object.freeze({ commit: 'deployment.commit', buildHash: 'deployment.build', imageDigest: 'deployment.image.digest', sbomRef: 'deployment.sbom.ref' });
24
+ export const IDENTITY_ATTRIBUTE_KEYS = Object.freeze(Object.values(ATTR));
25
+
26
+ const hex = (s) => (typeof s === 'string' && /^[0-9a-f]{12,64}$/i.test(s) ? s.toLowerCase() : null);
27
+ const digest = (s) => { const m = typeof s === 'string' ? /^(?:sha256:)?([0-9a-f]{64})$/i.exec(s) : null; return m ? `sha256:${m[1].toLowerCase()}` : null; };
28
+ const token = (s) => (typeof s === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(s) ? s : null);
29
+
30
+ /** Normalise an identity-bearing object ({commit, buildHash, imageDigest, sbomRef, environment, dirty}) to comparable values; an unusable value becomes null. */
31
+ export function normalizeIdentity(raw) {
32
+ const r = raw && typeof raw === 'object' ? raw : {};
33
+ return { commit: hex(r.commit), buildHash: token(r.buildHash), imageDigest: digest(r.imageDigest), sbomRef: token(r.sbomRef), environment: token(r.environment), dirty: r.dirty === true };
34
+ }
35
+
36
+ /** The identity an observation carries, from its approved attributes and its environment. */
37
+ export function observationIdentity(obs) {
38
+ const a = (obs && obs.attributes) || {};
39
+ return normalizeIdentity({ commit: a[ATTR.commit], buildHash: a[ATTR.buildHash], imageDigest: a[ATTR.imageDigest], sbomRef: a[ATTR.sbomRef], environment: obs && obs.environment });
40
+ }
41
+
42
+ const sameCommit = (a, b) => { const n = Math.min(a.length, b.length); return n >= 12 && a.slice(0, n) === b.slice(0, n); };
43
+
44
+ /**
45
+ * @param {object} observation a runtime observation (or an identity object)
46
+ * @param {object} snapshot the static snapshot's identity: {commit, buildHash, imageDigest, sbomRef, environment, dirty}
47
+ * @returns {{state:'matched'|'mismatched'|'unknown', compared:string[], agreed:string[], disagreed:string[], reasons:string[]}}
48
+ */
49
+ export function linkObservationToSnapshot(observation, snapshot) {
50
+ const o = observation && observation.attributes !== undefined ? observationIdentity(observation) : normalizeIdentity(observation);
51
+ const s = normalizeIdentity(snapshot);
52
+ const compared = []; const agreed = []; const disagreed = []; const reasons = [];
53
+ for (const f of IDENTITY_FIELDS) {
54
+ if (o[f] == null || s[f] == null) continue;
55
+ compared.push(f);
56
+ const eq = f === 'commit' ? sameCommit(o[f], s[f]) : o[f] === s[f];
57
+ (eq ? agreed : disagreed).push(f);
58
+ }
59
+ if (o.environment != null && s.environment != null) {
60
+ compared.push('environment');
61
+ if (o.environment === s.environment) agreed.push('environment'); else disagreed.push('environment');
62
+ }
63
+ if (disagreed.length) return { state: 'mismatched', compared, agreed, disagreed, reasons: disagreed.map((f) => `${f} differs between the observation and the snapshot`) };
64
+ const strong = agreed.filter((f) => STRONG_FIELDS.includes(f));
65
+ if (!strong.length) {
66
+ if (o.commit == null && o.buildHash == null && o.imageDigest == null) reasons.push('the observation carries no commit, build hash or image digest');
67
+ else if (s.commit == null && s.buildHash == null && s.imageDigest == null) reasons.push('the snapshot carries no commit, build hash or image digest');
68
+ else reasons.push('the observation and the snapshot carry different kinds of identity, so none can be compared');
69
+ return { state: 'unknown', compared, agreed, disagreed, reasons };
70
+ }
71
+ if (s.dirty && strong.length === 1 && strong[0] === 'commit') return { state: 'unknown', compared, agreed, disagreed, reasons: ['the snapshot has uncommitted changes: the code that was analysed is not the code the commit names'] };
72
+ return { state: 'matched', compared, agreed, disagreed, reasons: [`${strong.join(', ')} agree and nothing disagrees`] };
73
+ }
74
+
75
+ /** True only for a link that may be called corroboration. */
76
+ export const canCorroborate = (link) => !!link && link.state === 'matched';
package/src/mcp/tools.js CHANGED
@@ -28,6 +28,7 @@ import { redactString, redactFinding } from './redact.js';
28
28
  import { _remediationOf, normalizeFindings } from '../report/index.js';
29
29
  // X-206: the one projection of the verification record, shared with the JSON report, the text report and the autopilot response.
30
30
  import { verificationFields } from '../posture/verification/projection.js';
31
+ import { verifyPatch, additiveFields } from '../fix/verify-patch.js';
31
32
  import { boundaryFields } from '../lineage/deployment/projection.js';
32
33
  // Git-origin provenance (Finding Provenance M0/M1). Distinct from
33
34
  // `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
@@ -879,25 +880,14 @@ export const apply_fix = {
879
880
  }
880
881
  // Inline re-verify — the load-bearing gate. Must pass to write.
881
882
  let verdict;
883
+ let fixVerification = null;
882
884
  try {
883
885
  const _files = Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content]));
884
- if (process.env.AGENTIC_SECURITY_FIX_RUN_TESTS === '1') {
885
- // Addition #7 — connect the closed-loop verifier: add the project test
886
- // suite as a fourth verification leg (scan + lint + tests). Opt-in
887
- // because many repos have no runner and we must not fail-closed by
888
- // default. Normalized to the scan+lint verdict shape used below.
889
- const { verifyFixWithTests } = await import('../posture/fix-verify-loop.js');
890
- const t = await verifyFixWithTests({ scanRoot: ctx.sessionRoot, originalFindingStableId: f.stableId, files: _files });
891
- verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict, verificationRecord: t.verificationRecord ?? null };
892
- } else {
893
- const verifyFixCore = await getVerifyFixCore();
894
- verdict = await verifyFixCore({
895
- scanRoot: ctx.sessionRoot,
896
- originalFindingStableId: f.stableId,
897
- files: _files,
898
- fixMeta,
899
- });
900
- }
886
+ // One verifier for every route (E3-03). AGENTIC_SECURITY_FIX_RUN_TESTS used to swap in a second test runner here; verifyFix
887
+ // already carries the test leg, so the flag is now inert.
888
+ const central = await verifyPatch({ scanRoot: ctx.sessionRoot, finding: { id: f.id, stableId: f.stableId }, files: _files, fixMeta, route: 'mcp-apply-fix', mode: dry_run ? 'preview' : 'apply', verify: await getVerifyFixCore() });
889
+ verdict = central.verdict;
890
+ fixVerification = central.fixVerification;
901
891
  } catch (e) {
902
892
  return { _meta: META, applied: false, reason: `patch verification failed: ${e.message}` };
903
893
  }
@@ -909,6 +899,7 @@ export const apply_fix = {
909
899
  // X-201: the same version-1 verification record every surface emits (additive; null when none could be formed).
910
900
  verificationRecord: verdict.verificationRecord ?? null,
911
901
  ...verificationFields(verdict.verificationRecord),
902
+ ...(fixVerification ? additiveFields(fixVerification) : {}),
912
903
  };
913
904
  }
914
905
  // FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
@@ -929,7 +920,7 @@ export const apply_fix = {
929
920
  }
930
921
  const materialClassification = classifyFixMaterialRisk(filesForMaterialClassification);
931
922
  if (dry_run) {
932
- return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification };
923
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
933
924
  }
934
925
  const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta);
935
926
  if (approvalRefusal) return approvalRefusal;
@@ -960,7 +951,7 @@ export const apply_fix = {
960
951
  }
961
952
  let acceptance = null;
962
953
  try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
963
- return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord) }, acceptance, materialClassification };
954
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord) }, acceptance, materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
964
955
  }
965
956
 
966
957
  if (typeof f.fix?.replacement !== 'string') {
@@ -1118,21 +1109,8 @@ export const verify_fix = {
1118
1109
  // Best-effort: a missing/unsigned/tampered scan just means no PoC is
1119
1110
  // available to re-check, not a verify_fix failure — the rescan/lint/
1120
1111
  // tests legs below are independent of this and still apply.
1121
- let poc = null;
1122
- try {
1123
- const { scan: lastScan } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
1124
- const orig = lastScan && (lastScan.findings || []).find(f => f.stableId === stable_id);
1125
- if (orig && orig.poc && orig.poc.code) poc = { ...orig.poc, finding: orig };
1126
- } catch { /* best-effort lookup; poc stays null */ }
1127
-
1128
- const verifyFixCore = await getVerifyFixCore();
1129
- const r = await verifyFixCore({
1130
- scanRoot: ctx.sessionRoot,
1131
- originalFindingStableId: stable_id,
1132
- files: confined,
1133
- poc,
1134
- fixMeta,
1135
- });
1112
+ const central = await verifyPatch({ scanRoot: ctx.sessionRoot, finding: { stableId: stable_id }, files: confined, fixMeta, route: 'mcp-verify-fix', mode: 'verify', verify: await getVerifyFixCore() });
1113
+ const r = central.verdict;
1136
1114
  return {
1137
1115
  _meta: META,
1138
1116
  ok: r.ok,
@@ -1154,6 +1132,7 @@ export const verify_fix = {
1154
1132
  // `not-run` when no exploit oracle executed against the patch, never a bare pass/fail.
1155
1133
  verificationRecord: r.verificationRecord ?? null,
1156
1134
  ...verificationFields(r.verificationRecord),
1135
+ ...additiveFields(central.fixVerification),
1157
1136
  };
1158
1137
  } catch (e) {
1159
1138
  return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
@@ -0,0 +1,64 @@
1
+ // Canonical scan output (E6-06).
2
+ //
3
+ // A scan result mixes two kinds of fact. Some are about the CODE: which findings exist, where, with what evidence. Those must be identical on every
4
+ // run over the same input, whatever order the files were read in, however many scans ran at once, and whenever it happened. The rest are about the
5
+ // RUN: when it started, how long each stage took, which stage finished first. Those differ every time and are not a defect.
6
+ //
7
+ // This module splits them:
8
+ // canonical findings (and the other finding-bearing lists) with volatile fields removed, numbers rounded to a fixed precision, object keys sorted,
9
+ // lists in a total order (file, line, vuln, stableId, id, then the serialized record as the last tiebreak). Hashed into `digest`.
10
+ // run timestamps, durations, telemetry, scan id; kept, separate, and never part of the digest
11
+ // provenance what produced the canonical part: scanner version, rule-pack hash, and each source of NONDETERMINISM that was active (a network feed,
12
+ // a model), named so a reader knows which results could legitimately differ
13
+ //
14
+ // The digest therefore answers one question: did two runs find the same things? A difference in `run` never changes it.
15
+ import { createHash } from 'node:crypto';
16
+ import { computeRulePackHash } from '../posture/deterministic.js';
17
+
18
+ const LISTS = Object.freeze(['findings', 'secrets', 'logicVulns', 'supplyChain']);
19
+ // fields inside a finding that describe the run, not the code
20
+ const VOLATILE_FINDING_KEYS = new Set(['detectedAt', 'scannedAt', 'ts', 'timestamp', 'elapsedMs', 'durationMs', 'scanId']);
21
+ const PRECISION = 6;
22
+
23
+ const round = (n) => (Number.isInteger(n) ? n : Math.round(n * 10 ** PRECISION) / 10 ** PRECISION);
24
+ function clean(v) {
25
+ if (Array.isArray(v)) return v.map(clean);
26
+ if (v && typeof v === 'object') {
27
+ const o = {};
28
+ for (const k of Object.keys(v).sort()) { if (VOLATILE_FINDING_KEYS.has(k) || v[k] === undefined) continue; o[k] = clean(v[k]); }
29
+ return o;
30
+ }
31
+ return typeof v === 'number' && Number.isFinite(v) ? round(v) : v;
32
+ }
33
+ const s = (x) => (x === undefined || x === null ? '' : String(x));
34
+ function order(a, b) {
35
+ return s(a.file).localeCompare(s(b.file), 'en') || (Number(a.line) || 0) - (Number(b.line) || 0) || s(a.vuln || a.title).localeCompare(s(b.vuln || b.title), 'en')
36
+ || s(a.stableId).localeCompare(s(b.stableId), 'en') || s(a.id).localeCompare(s(b.id), 'en') || JSON.stringify(a).localeCompare(JSON.stringify(b), 'en');
37
+ }
38
+
39
+ /** Sources of nondeterminism that are active for this process, named. */
40
+ export function nondeterminismSources(env = process.env) {
41
+ const out = [];
42
+ if (env.AGENTIC_SECURITY_LLM_ENDPOINT && env.AGENTIC_SECURITY_LLM_VALIDATE !== '0') out.push({ source: 'llm-validator', reason: 'a model endpoint is configured and the validator is on: its verdicts can differ between runs', model: env.AGENTIC_SECURITY_LLM_MODEL || null });
43
+ if (env.AGENTIC_SECURITY_DETERMINISTIC !== '1' && env.AGENTIC_SECURITY_OFFLINE !== '1') out.push({ source: 'network-feeds', reason: 'vulnerability and registry feeds are fetched and cached; their content changes over time' });
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * @param {object} scan a scan result
49
+ * @param {{env?:object, scannerVersion?:string}} opts
50
+ * @returns {{canonical:object, digest:string, run:object, provenance:object}}
51
+ */
52
+ export function canonicalizeScan(scan, opts = {}) {
53
+ const canonical = {};
54
+ for (const k of LISTS) if (Array.isArray(scan && scan[k])) canonical[k] = scan[k].map(clean).sort(order);
55
+ const digest = createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
56
+ const meta = (scan && (scan._scanMeta || scan.meta)) || {};
57
+ const run = {
58
+ scanId: meta.scanId ?? null, startedAt: meta.startedAt ?? null, durationMs: meta.durationMs ?? null,
59
+ telemetry: scan && scan.telemetry ? scan.telemetry : null,
60
+ fileTimings: Array.isArray(meta.fileTimings) ? meta.fileTimings : null,
61
+ };
62
+ const provenance = { scannerVersion: opts.scannerVersion ?? null, rulePackHash: computeRulePackHash(), nondeterminism: nondeterminismSources(opts.env), canonicalPrecision: PRECISION };
63
+ return { canonical, digest, run, provenance };
64
+ }