@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 (136) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bin/agentic-security.js +131 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/1919.index.js +445 -0
  6. package/dist/2862.index.js +325 -0
  7. package/dist/3499.index.js +5 -1
  8. package/dist/3531.index.js +261 -0
  9. package/dist/3596.index.js +103 -0
  10. package/dist/4113.index.js +15 -3
  11. package/dist/4171.index.js +319 -0
  12. package/dist/5637.index.js +10 -4
  13. package/dist/6359.index.js +433 -0
  14. package/dist/7838.index.js +15 -3
  15. package/dist/8551.index.js +1192 -0
  16. package/dist/8587.index.js +410 -0
  17. package/dist/8714.index.js +98 -0
  18. package/dist/8798.index.js +218 -0
  19. package/dist/9220.index.js +35 -3
  20. package/dist/9560.index.js +26 -3
  21. package/dist/agentic-security.mjs +51 -51
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/package.json +20 -3
  24. package/src/dataflow/CLAUDE.md +9 -0
  25. package/src/dataflow/catalog-haskell.js +37 -0
  26. package/src/dataflow/catalog.js +4 -0
  27. package/src/dataflow/engine.js +33 -3
  28. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  29. package/src/dataflow/privacy-catalog.js +5 -1
  30. package/src/discovery/CLAUDE.md +6 -0
  31. package/src/discovery/index.js +12 -3
  32. package/src/discovery/lenses.js +4 -1
  33. package/src/discovery/partition.js +28 -0
  34. package/src/egress/redact.js +8 -0
  35. package/src/engine.js +125 -14
  36. package/src/ir/index.js +39 -2
  37. package/src/language/CLAUDE.md +74 -0
  38. package/src/language/accuracy.js +90 -0
  39. package/src/language/aibom.js +307 -0
  40. package/src/language/assurance.js +193 -0
  41. package/src/language/bom-validate.js +227 -0
  42. package/src/language/bom.js +236 -0
  43. package/src/language/bridges.js +392 -0
  44. package/src/language/compliance-map.js +61 -0
  45. package/src/language/context.js +199 -0
  46. package/src/language/contracts.js +339 -0
  47. package/src/language/discovery.js +359 -0
  48. package/src/language/engine-pass.js +120 -0
  49. package/src/language/fix-lifecycle.js +120 -0
  50. package/src/language/haskell-adapter.js +51 -0
  51. package/src/language/haskell-disclosure.js +170 -0
  52. package/src/language/haskell-findings.js +44 -0
  53. package/src/language/haskell-fix.js +409 -0
  54. package/src/language/haskell-grammar.js +78 -0
  55. package/src/language/haskell-guards.js +382 -0
  56. package/src/language/haskell-ir.js +1377 -0
  57. package/src/language/haskell-llm.js +128 -0
  58. package/src/language/haskell-manifests.js +1290 -0
  59. package/src/language/haskell-models.js +278 -0
  60. package/src/language/haskell-parser.js +1073 -0
  61. package/src/language/haskell-resolved-graph.js +414 -0
  62. package/src/language/haskell-sca.js +387 -0
  63. package/src/language/haskell-security-rules.js +332 -0
  64. package/src/language/haskell-supply.js +140 -0
  65. package/src/language/haskell-sweep.js +85 -0
  66. package/src/language/haskell-syntax.js +895 -0
  67. package/src/language/haskell-web.js +749 -0
  68. package/src/language/nix-adapter.js +42 -0
  69. package/src/language/nix-agents.js +155 -0
  70. package/src/language/nix-build-trust.js +790 -0
  71. package/src/language/nix-closure.js +331 -0
  72. package/src/language/nix-eval-isolation.js +274 -0
  73. package/src/language/nix-fix.js +268 -0
  74. package/src/language/nix-grammar.js +97 -0
  75. package/src/language/nix-inventory.js +379 -0
  76. package/src/language/nix-ir.js +642 -0
  77. package/src/language/nix-parser.js +908 -0
  78. package/src/language/nix-privacy.js +114 -0
  79. package/src/language/nix-sca.js +358 -0
  80. package/src/language/nix-script-taint.js +885 -0
  81. package/src/language/nix-secrets.js +396 -0
  82. package/src/language/nixos-hardening.js +564 -0
  83. package/src/language/nixos-module-resolver.js +595 -0
  84. package/src/language/nixos-option-catalog.js +96 -0
  85. package/src/language/pragma.js +150 -0
  86. package/src/language/resolved-pass.js +174 -0
  87. package/src/language/secrets.js +323 -0
  88. package/src/language/state-artifacts.js +78 -0
  89. package/src/language/support-registry.js +289 -0
  90. package/src/language/trusted-inputs.js +38 -0
  91. package/src/language/witness.js +185 -0
  92. package/src/lineage/CLAUDE.md +8 -0
  93. package/src/lineage/coverage.js +2 -0
  94. package/src/lineage/cross-repo-link.js +22 -0
  95. package/src/lineage/graph-builder.js +5 -1
  96. package/src/lineage/haskell-view.js +319 -0
  97. package/src/lineage/index.js +8 -1
  98. package/src/lineage/nix-view.js +198 -0
  99. package/src/lineage/sink-registry.js +10 -1
  100. package/src/lineage/source-registry.js +3 -0
  101. package/src/lineage/transform-catalog.js +12 -0
  102. package/src/llm-validator/index.js +16 -2
  103. package/src/lsp/server.js +69 -9
  104. package/src/mcp/CLAUDE.md +7 -0
  105. package/src/mcp/tools.js +26 -3
  106. package/src/pipeline/coverage-ledger.js +0 -0
  107. package/src/pipeline/scan-health.js +19 -1
  108. package/src/posture/accuracy-scorecard.js +51 -0
  109. package/src/posture/aibom.js +61 -3
  110. package/src/posture/artifact-registry.js +12 -0
  111. package/src/posture/auditor-walkthrough.js +41 -0
  112. package/src/posture/clustering.js +2 -2
  113. package/src/posture/evidence-bundle.js +7 -1
  114. package/src/posture/execution-proof.js +15 -0
  115. package/src/posture/falsification.js +7 -0
  116. package/src/posture/family-resolve.js +7 -0
  117. package/src/posture/fix-history.js +40 -0
  118. package/src/posture/fix-verify.js +15 -3
  119. package/src/posture/provenance/git-evidence.js +24 -0
  120. package/src/posture/provenance/origin-resolver.js +14 -2
  121. package/src/posture/provenance/predicate-replay.js +11 -2
  122. package/src/posture/provenance/sca-origin.js +20 -3
  123. package/src/posture/sbom-diff.js +7 -6
  124. package/src/posture/sbom.js +85 -32
  125. package/src/posture/sca-policy.js +10 -1
  126. package/src/posture/sca-verdict.js +8 -1
  127. package/src/posture/secret-history.js +62 -18
  128. package/src/posture/stable-id.js +21 -2
  129. package/src/posture/state-dir.js +21 -0
  130. package/src/posture/threat-model-auto.js +4 -4
  131. package/src/posture/triage.js +18 -1
  132. package/src/posture/watch-mode.js +34 -3
  133. package/src/pr-delta.js +9 -4
  134. package/src/report/index.js +35 -9
  135. package/src/runScan.js +56 -5
  136. package/src/sast/pipeline.js +34 -1
@@ -0,0 +1,749 @@
1
+ // Haskell web entry points, authentication and authorization (HS-006).
2
+ //
3
+ // Frameworks modelled (versions recorded in HS_WEB_FRAMEWORKS): Scotty, WAI/Warp, Servant, Yesod. For each
4
+ // route the analyser records the method, the path, the handler it could resolve, and what it can PROVE about
5
+ // who may call it, from the code, never from a type annotation, a server being present, or a name:
6
+ //
7
+ // * Warp is a server, not authorization. A type annotation is not enforcement. An authentication
8
+ // declaration that is not applied to the handler (a middleware defined but never installed, a guard
9
+ // function nobody calls) earns no credit.
10
+ // * An auth guard is a function that READS a credential (Authorization / Cookie header, a JWT decode, a
11
+ // session lookup) AND has a rejection path (a 401/403 response, throwError err40x, finish/raise, notAuthenticated).
12
+ // Guards are summarised on the IR across modules, transitively.
13
+ // * A guard protects a handler only if it DOMINATES the first sensitive operation: a guard that runs after
14
+ // the database call is reported as a late guard, not credited.
15
+ // * Object ownership (BOLA) is judged on a comparison or a scoped query that involves the authenticated
16
+ // principal; role checks (BFLA) on a role comparison or role guard; CSRF only for cookie-authenticated
17
+ // state-changing routes, and Servant/Authorization-header APIs are not CSRF-exposed.
18
+ // * What cannot be resolved (a handler built at runtime, routes registered in a loop, an opaque middleware)
19
+ // is recorded as `unknown` coverage, never as protected and never omitted.
20
+
21
+ import { buildHaskellIR, lowerHandlerExpr, resolveName } from './haskell-ir.js';
22
+ import { qualifyAmbiguous, HS_WEB_FRAMEWORKS, HS_WEB_MODEL_VERSION, modelStatus } from './haskell-models.js';
23
+
24
+ export const HS_WEB_RULES_VERSION = 'haskell-web-rules/1';
25
+
26
+ export const WEB_RULES = Object.freeze({
27
+ 'hs-route-missing-auth': { cwe: 'CWE-306', severity: 'high', family: 'missing-authentication', vuln: 'State-changing route without authentication', remediation: 'Require an authenticated principal before the handler performs any state change.' },
28
+ 'hs-route-late-auth': { cwe: 'CWE-306', severity: 'high', family: 'missing-authentication', vuln: 'Authentication check runs after a sensitive operation', remediation: 'Move the authentication guard before the first database, file or process operation.' },
29
+ 'hs-route-bola': { cwe: 'CWE-639', severity: 'high', family: 'broken-object-authorization', vuln: 'Object looked up by a client-supplied id without an ownership check', remediation: 'Compare the object owner (or tenant) with the authenticated principal, or scope the query by the principal.' },
30
+ 'hs-route-bfla': { cwe: 'CWE-285', severity: 'high', family: 'broken-function-authorization', vuln: 'Privileged route without a role or permission check', remediation: 'Require an admin/role check in addition to authentication.' },
31
+ 'hs-route-csrf': { cwe: 'CWE-352', severity: 'medium', family: 'csrf', vuln: 'State-changing route authenticated by a cookie with no CSRF protection', remediation: 'Add a CSRF token check (or SameSite cookies plus an Origin check) for cookie-authenticated state changes.' },
32
+ 'hs-yesod-csrf-disabled': { cwe: 'CWE-352', severity: 'medium', family: 'csrf', vuln: 'Yesod middleware overridden without CSRF protection', remediation: 'Keep `defaultCsrfMiddleware` in `yesodMiddleware`.' },
33
+ });
34
+
35
+ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
36
+ const SCOTTY_METHODS = { get: 'GET', post: 'POST', put: 'PUT', delete: 'DELETE', patch: 'PATCH', options: 'OPTIONS', matchAny: 'ANY', addroute: 'ANY' };
37
+ const SENSITIVE_PREFIX = ['Database.', 'System.Process.', 'System.Directory.', 'Prelude.readFile', 'Prelude.writeFile', 'Prelude.appendFile', 'System.IO.', 'Network.HTTP.Simple.', 'Network.HTTP.Client.'];
38
+ const PERSIST_FNS = new Set(['get', 'getBy', 'selectFirst', 'selectList', 'insert', 'insert_', 'update', 'updateWhere', 'delete', 'deleteWhere', 'replace', 'runDB', 'getJust', 'upsert']);
39
+ const GUARD_PRIMITIVES = new Set(['Yesod.Core.requireAuthId', 'Yesod.Core.requireAuth', 'Yesod.Auth.requireAuthId', 'Yesod.Auth.requireAuth', 'Network.Wai.Middleware.HttpAuth.basicAuth']);
40
+ const CREDENTIAL_HEADER = /^(?:authorization|cookie|x-api-key|x-auth-token|x-access-token)$/i;
41
+ const ADMIN_PATH = /(?:^|\/)(?:admin|internal|manage|superuser)(?:\/|$|:)/i;
42
+ const ROLE_CALL = /(?:^|\.)(?:isAdmin|hasRole|requireAdmin|checkRole|hasPermission|requireRole|requirePermission|isSuperuser)$/i;
43
+ const OWNER_CALL = /(?:^|\.)(?:checkOwner|ownedBy|belongsTo|isOwner|requireOwner|authorizeOwner)$/i;
44
+ const CSRF_CALL = /csrf|xsrf/i;
45
+
46
+ // ── AST helpers ──────────────────────────────────────────────────────────────
47
+ function unparen(e) { while (e && e.t === 'paren') e = e.e; return e; }
48
+
49
+ /** Normalise `f a b`, `f a $ b` and `(f a) b` to { head, args }. */
50
+ export function spine(e) {
51
+ e = unparen(e);
52
+ if (!e) return null;
53
+ if (e.t === 'op' && e.op === '$') {
54
+ const l = spine(e.l);
55
+ if (l) return { head: l.head, args: [...l.args, e.r] };
56
+ return null;
57
+ }
58
+ if (e.t === 'app') {
59
+ const f = unparen(e.f);
60
+ if (f && (f.t === 'app' || (f.t === 'op' && f.op === '$'))) {
61
+ const inner = spine(f);
62
+ if (inner) return { head: inner.head, args: [...inner.args, ...e.args] };
63
+ }
64
+ return f && f.t === 'var' ? { head: f, args: e.args } : null;
65
+ }
66
+ if (e.t === 'var') return { head: e, args: [] };
67
+ return null;
68
+ }
69
+
70
+ function* walkAst(node, depth = 0) {
71
+ if (!node || typeof node !== 'object' || depth > 120) return;
72
+ if (Array.isArray(node)) { for (const x of node) yield* walkAst(x, depth + 1); return; }
73
+ if (typeof node.t === 'string') yield node;
74
+ for (const k of Object.keys(node)) {
75
+ if (k === 'line' || k === 't') continue;
76
+ const v = node[k];
77
+ if (v && typeof v === 'object') yield* walkAst(v, depth + 1);
78
+ }
79
+ }
80
+
81
+ const strLit = (e) => { e = unparen(e); return e && e.t === 'lit' && e.kind === 'str' ? e.v : null; };
82
+
83
+ // ── analysis context ─────────────────────────────────────────────────────────
84
+ class Ctx {
85
+ constructor(files) {
86
+ this.files = files;
87
+ this.ir = buildHaskellIR(files);
88
+ this.project = this.ir.project;
89
+ this.modules = [...this.ir.modules.values()];
90
+ this.fnByQid = new Map();
91
+ for (const f of Object.values(this.ir.perFile)) for (const fn of f.functions) this.fnByQid.set(fn.qid, fn);
92
+ this.guardMemo = new Map();
93
+ this.gaps = [];
94
+ this.limits = [];
95
+ }
96
+
97
+ modOf(file) { return this.modules.find((m) => m.file === file); }
98
+
99
+ /** Import-qualified name for a head variable, or null when it cannot be attributed. */
100
+ qualify(mod, head) {
101
+ const r = resolveName(this.project, mod, head.name, head.qual || null);
102
+ if (r.kind === 'external') {
103
+ if (r.module) return { name: `${r.module}.${head.name}`, certain: r.certain !== false };
104
+ const qa = qualifyAmbiguous(head.name, r.candidates);
105
+ if (qa) return { name: `${qa.module}.${head.name}`, certain: false };
106
+ return null;
107
+ }
108
+ if (r.kind === 'def') return { name: `${r.mod}.${r.name}`, qid: r.qid, def: r.def, mod: r.mod };
109
+ return null;
110
+ }
111
+
112
+ fnFor(qualified) {
113
+ if (!qualified || !qualified.qid) return null;
114
+ return this.fnByQid.get(qualified.qid) || null;
115
+ }
116
+ }
117
+
118
+ // ── guard summaries (on the IR, across modules, transitive) ───────────────────
119
+ function callsOf(fn) { return (fn && fn.calls) || []; }
120
+
121
+ function headerArg(c) { const a = (c.args || [])[0]; return a && a.kind === 'literal' && typeof a.value === 'string' ? a.value : null; }
122
+
123
+ function credentialKind(c) {
124
+ if (c.callee === 'Web.Scotty.header' || c.callee === 'Web.Scotty.Trans.header') {
125
+ const h = headerArg(c);
126
+ if (h && CREDENTIAL_HEADER.test(h)) return /cookie/i.test(h) ? 'cookie' : 'token';
127
+ }
128
+ if (c.callee === 'Web.Cookie.parseCookies') return 'cookie';
129
+ if (c.callee === 'Network.Wai.requestHeaders') return 'token';
130
+ if (c.callee.startsWith('Web.JWT.')) return 'token';
131
+ if (GUARD_PRIMITIVES.has(c.callee)) return c.callee.includes('HttpAuth') ? 'basic' : 'session';
132
+ return null;
133
+ }
134
+
135
+ function isRejection(c) {
136
+ const name = c.callee;
137
+ const argNames = (c.args || []).flatMap((a) => (a && a.kind === 'ident' ? [a.name] : []));
138
+ if ((name === 'Web.Scotty.status' || name === 'Web.Scotty.Trans.status') && argNames.some((n) => /(?:status40[13]|unauthorized401|forbidden403)$/.test(n))) return true;
139
+ if (name === 'Network.Wai.responseLBS' && argNames.some((n) => /(?:status40[13]|unauthorized401|forbidden403)$/.test(n))) return true;
140
+ if (/^(?:Servant|Servant\.Server|Control\.Monad\.Except)\.throwError$/.test(name) && (c.args || []).some((a) => a && ((a.kind === 'ident' && /err40[13]$/.test(a.name)) || (a.kind === 'call' && /err40[13]$/.test(a.callee))))) return true;
141
+ if (/^Yesod\.Core\.(?:permissionDenied|notAuthenticated)$/.test(name)) return true;
142
+ return false;
143
+ }
144
+
145
+ function guardSummary(ctx, fn, depth = 0, seen = new Set()) {
146
+ if (!fn) return { auth: false, kinds: [], role: false, owner: false, csrf: false };
147
+ if (ctx.guardMemo.has(fn.qid)) return ctx.guardMemo.get(fn.qid);
148
+ if (seen.has(fn.qid) || depth > 3) return { auth: false, kinds: [], role: false, owner: false, csrf: false };
149
+ seen.add(fn.qid);
150
+ const kinds = new Set();
151
+ let reads = false, rejects = false, role = false, owner = false, csrf = false, primitive = false;
152
+ for (const c of callsOf(fn)) {
153
+ const k = credentialKind(c);
154
+ if (k) { kinds.add(k); reads = true; if (GUARD_PRIMITIVES.has(c.callee)) primitive = true; }
155
+ if (isRejection(c)) rejects = true;
156
+ if (ROLE_CALL.test(c.callee || '')) role = true;
157
+ if (OWNER_CALL.test(c.callee || '')) owner = true;
158
+ if (CSRF_CALL.test(c.callee || '')) csrf = true;
159
+ if (c.hs && c.hs.status === 'resolved' && c.hs.target && !c.hs.inlined) {
160
+ const sub = guardSummary(ctx, ctx.fnByQid.get(c.hs.target), depth + 1, seen);
161
+ if (sub.auth) { reads = true; rejects = true; sub.kinds.forEach((x) => kinds.add(x)); }
162
+ if (sub.role) role = true; if (sub.owner) owner = true; if (sub.csrf) csrf = true;
163
+ }
164
+ }
165
+ // literal comparisons against an admin role count as a role check
166
+ for (const n of Object.values(fn.cfg.nodes)) for (const e of exprsOfNode(n)) for (const x of walkIr(e)) {
167
+ if (x.kind === 'binary' && (x.op === '==' || x.op === '===' || x.op === '!=')) {
168
+ const sides = [x.left, x.right];
169
+ if (sides.some((s) => s && s.kind === 'literal' && typeof s.value === 'string' && /^(?:admin|administrator|superuser|root)$/i.test(s.value))) role = true;
170
+ if (sides.some((s) => s && s.kind === 'literal' && /^(?:Admin|SuperUser|Administrator)$/.test(String(s.value)) && s.hs && s.hs.con)) role = true;
171
+ }
172
+ }
173
+ const out = { auth: primitive || (reads && rejects), kinds: [...kinds], role, owner, csrf };
174
+ ctx.guardMemo.set(fn.qid, out);
175
+ return out;
176
+ }
177
+
178
+ function exprsOfNode(n) { return [n.source, n.value, n.cond, ...(n.kind === 'call' ? [{ kind: 'call', callee: n.callee, args: n.args || [], hs: n.hs }] : [])].filter(Boolean); }
179
+ function kidsIr(e) {
180
+ const out = [];
181
+ for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') out.push(e[k]);
182
+ for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) out.push(...e[k]);
183
+ if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) out.push(p.value);
184
+ return out;
185
+ }
186
+ function* walkIr(e, depth = 0) { if (!e || typeof e !== 'object' || depth > 80) return; yield e; for (const c of kidsIr(e)) yield* walkIr(c, depth + 1); }
187
+ const identsIr = (e) => { const out = new Set(); for (const x of walkIr(e)) { if (x.kind === 'ident' && x.name) out.add(x.name); if (x.kind === 'member' && x.object) { for (const y of walkIr(x.object)) if (y.kind === 'ident') out.add(y.name); } } return out; };
188
+
189
+ // ── handler summary ─────────────────────────────────────────────────────────
190
+ // Ordered call list (program order) + principal / id / ownership facts for one handler function IR.
191
+ function handlerFacts(ctx, fn) {
192
+ const ordered = [];
193
+ const nodes = Object.entries(fn.cfg.nodes).map(([id, n]) => ({ id, ...n })).sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)));
194
+ const assigns = [];
195
+ const conds = [];
196
+ for (const n of nodes) {
197
+ if (n.kind === 'assign' && n.source) assigns.push(n);
198
+ if (n.kind === 'if' && n.cond) conds.push(n.cond);
199
+ for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
200
+ if (e.kind === 'call' && typeof e.callee === 'string') ordered.push({ call: e, line: e.line || n.line, node: n });
201
+ if (e.kind === 'union' && e.hs && e.hs.branchConds) for (const bc of e.hs.branchConds) conds.push(bc.cond);
202
+ }
203
+ }
204
+ const isGuardCall = (c) => {
205
+ if (GUARD_PRIMITIVES.has(c.callee)) return true;
206
+ if (c.hs && c.hs.status === 'resolved' && c.hs.target) return guardSummary(ctx, ctx.fnByQid.get(c.hs.target)).auth;
207
+ return false;
208
+ };
209
+ const isSensitive = (c) => {
210
+ if (SENSITIVE_PREFIX.some((p) => c.callee.startsWith(p))) return true;
211
+ const bare = c.callee.split('.').pop();
212
+ if (PERSIST_FNS.has(bare) && !/^(?:Prelude|Data|Web)\./.test(c.callee)) return true;
213
+ if (c.hs && c.hs.status === 'resolved' && c.hs.target) {
214
+ const sub = ctx.fnByQid.get(c.hs.target);
215
+ if (sub && callsOf(sub).some((x) => SENSITIVE_PREFIX.some((p) => (x.callee || '').startsWith(p)))) return true;
216
+ }
217
+ return false;
218
+ };
219
+ const guardIdx = ordered.findIndex((o) => isGuardCall(o.call));
220
+ const sensIdx = ordered.findIndex((o) => isSensitive(o.call));
221
+ const principals = new Set();
222
+ for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
223
+ // ids read from the request
224
+ const idVars = new Set();
225
+ for (const a of assigns) {
226
+ const s = a.source;
227
+ if (s.kind === 'call' && /^Web\.Scotty(?:\.Trans)?\.(?:param|captureParam|queryParam|pathParam)$/.test(s.callee)) {
228
+ const nm = headerArg(s);
229
+ if (nm && /id$/i.test(nm)) idVars.add(a.target);
230
+ }
231
+ }
232
+ return { ordered, guardIdx, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
233
+ }
234
+
235
+ function hasOwnership(ctx, facts) {
236
+ const { principals, idVars } = facts;
237
+ const mentionsPrincipal = (e) => { for (const n of identsIr(e)) if (principals.has(n)) return true; return false; };
238
+ // 1. a comparison involving the principal
239
+ for (const c of facts.conds) for (const x of walkIr(c)) {
240
+ if (x.kind === 'binary' && ['==', '===', '!='].includes(x.op) && (mentionsPrincipal(x.left) || mentionsPrincipal(x.right))) return { kind: 'principal-comparison' };
241
+ if (x.kind === 'call' && /(?:^|\.)(?:elem|notElem)$/.test(x.callee || '') && (x.args || []).some(mentionsPrincipal)) return { kind: 'principal-membership' };
242
+ }
243
+ for (const o of facts.ordered) {
244
+ if (OWNER_CALL.test(o.call.callee || '')) return { kind: 'owner-check-call' };
245
+ // 2. a lookup scoped by BOTH the id and the principal
246
+ if (facts.isSensitive(o.call) && (o.call.args || []).some(mentionsPrincipal) && (o.call.args || []).some((a) => [...identsIr(a)].some((n) => idVars.has(n)))) return { kind: 'principal-scoped-query' };
247
+ }
248
+ // 3. a project function receiving both
249
+ for (const o of facts.ordered) if (o.call.hs && o.call.hs.status === 'resolved' && (o.call.args || []).some(mentionsPrincipal) && (o.call.args || []).some((a) => [...identsIr(a)].some((n) => idVars.has(n)))) return { kind: 'principal-scoped-helper', callee: o.call.callee };
250
+ return null;
251
+ }
252
+
253
+ // ── route record ─────────────────────────────────────────────────────────────
254
+ function newRoute(r) { return { auth: { status: 'unknown', kind: null, evidence: [] }, role: { status: 'none', evidence: [] }, params: [], uploads: false, limits: [], ...r }; }
255
+
256
+ function analyzeRoute(ctx, route, handlerFn, opts) {
257
+ const R = route;
258
+ if (!handlerFn) { R.handler = R.handler || { kind: 'unknown' }; R.auth = { status: 'unknown', kind: null, evidence: [], reason: 'handler not resolved' }; return; }
259
+ const facts = handlerFacts(ctx, handlerFn);
260
+ for (const v of opts.principalVars || []) facts.principals.add(v);
261
+ for (const v of opts.idVars || []) facts.idVars.add(v);
262
+ const gs = guardSummary(ctx, handlerFn);
263
+ R.facts = facts;
264
+ R.credentialKinds = new Set(gs.kinds);
265
+ // global (middleware / isAuthorized / type) protection first
266
+ if (opts.globalAuth) { R.auth = { status: 'authenticated', kind: opts.globalAuth.kind, evidence: [{ ...opts.globalAuth, credential: opts.globalAuth.kind, kind: 'global-guard' }] }; R.credentialKinds.add(opts.globalAuth.kind); }
267
+ else if (opts.typeAuth) { R.auth = { status: 'authenticated', kind: opts.typeAuth.kind, evidence: [{ ...opts.typeAuth, credential: opts.typeAuth.kind, kind: 'type-combinator' }] }; R.credentialKinds.add(opts.typeAuth.kind); }
268
+ else if (facts.guardIdx >= 0) {
269
+ if (facts.sensIdx >= 0 && facts.guardIdx > facts.sensIdx) {
270
+ R.auth = { status: 'late', kind: null, evidence: [{ kind: 'late-guard', callee: facts.ordered[facts.guardIdx].call.callee, line: facts.ordered[facts.guardIdx].line }] };
271
+ } else {
272
+ const g = facts.ordered[facts.guardIdx].call;
273
+ const sub = g.hs && g.hs.target ? guardSummary(ctx, ctx.fnByQid.get(g.hs.target)) : { kinds: g.callee.includes('requireAuth') ? ['session'] : [] };
274
+ R.auth = { status: 'authenticated', kind: (sub.kinds && sub.kinds[0]) || null, evidence: [{ kind: 'handler-guard', callee: g.callee, line: facts.ordered[facts.guardIdx].line }] };
275
+ (sub.kinds || []).forEach((k) => R.credentialKinds.add(k));
276
+ }
277
+ } else R.auth = { status: 'none', kind: null, evidence: [] };
278
+ // role
279
+ const roleCall = facts.ordered.find((o) => ROLE_CALL.test(o.call.callee || ''));
280
+ const anyRole = gs.role || !!roleCall || (opts.globalRole === true) || (opts.typeRole === true);
281
+ if (anyRole) R.role = { status: 'present', evidence: [{ kind: roleCall ? 'role-call' : 'role-guard' }] };
282
+ // uploads
283
+ R.uploads = facts.ordered.some((o) => /\.files$/.test(o.call.callee) || /fileUpload|FileInfo|Multipart/i.test(o.call.callee));
284
+ // csrf evidence
285
+ R.csrf = gs.csrf || facts.ordered.some((o) => CSRF_CALL.test(o.call.callee || ''));
286
+ R.sensitive = facts.sensIdx >= 0;
287
+ // ownership / BOLA inputs
288
+ R.ownership = hasOwnership(ctx, facts);
289
+ R.idLookups = facts.ordered.filter((o) => facts.isSensitive(o.call) && (o.call.args || []).some((a) => [...identsIr(a)].some((n) => facts.idVars.has(n))));
290
+ }
291
+
292
+ // ── Scotty ─────────────────────────────────────────────────────────────────
293
+ function scottyRoutes(ctx, add) {
294
+ const globalMw = [];
295
+ for (const mod of ctx.modules) {
296
+ if (!/\.hs$/i.test(mod.file)) continue;
297
+ for (const fun of mod.groups.funs) {
298
+ for (const clause of fun.clauses) {
299
+ const visit = (node, inLoop) => {
300
+ for (const n of walkAst(node)) {
301
+ if (n.t !== 'app' && n.t !== 'op' && n.t !== 'var') continue;
302
+ const sp = spine(n);
303
+ if (!sp || !sp.head) continue;
304
+ const q = ctx.qualify(mod, sp.head);
305
+ if (!q || !/^Web\.Scotty(?:\.Trans)?\./.test(q.name)) continue;
306
+ const fn = q.name.split('.').pop();
307
+ if (fn === 'middleware' && sp.args[0]) { globalMw.push({ mod, expr: sp.args[0], file: mod.file, line: sp.head.line || fun.line }); continue; }
308
+ if (!(fn in SCOTTY_METHODS)) continue;
309
+ if (sp.args.length < 2) continue;
310
+ const path = strLit(sp.args[0]);
311
+ const handlerAst = sp.args[sp.args.length - 1];
312
+ add({ framework: 'scotty', method: SCOTTY_METHODS[fn], path: path ?? '<dynamic>', dynamicPath: path === null, file: mod.file, line: sp.head.line || fun.line, mod, handlerAst, inLoop, owner: `${mod.name}.${fun.name}` });
313
+ }
314
+ };
315
+ visit(clause.rhs.body, false);
316
+ // routes registered inside a traversal are dynamic registration
317
+ for (const n of walkAst(clause.rhs.body)) {
318
+ const sp = n.t === 'app' || n.t === 'op' ? spine(n) : null;
319
+ if (sp && sp.head && /^(?:forM_|mapM_|forM|mapM|traverse_|for_|forever)$/.test(sp.head.name)) {
320
+ for (const arg of sp.args) for (const inner of walkAst(arg)) {
321
+ const isp = inner.t === 'app' || inner.t === 'op' ? spine(inner) : null;
322
+ if (isp && isp.head) { const q = ctx.qualify(mod, isp.head); if (q && /^Web\.Scotty(?:\.Trans)?\.(?:get|post|put|delete|patch)$/.test(q.name)) ctx.gaps.push({ kind: 'dynamic-route-registration', detail: `Scotty ${q.name.split('.').pop()} registered inside ${sp.head.name}: the route set is computed at runtime`, file: mod.file, line: isp.head.line || fun.line }); }
323
+ }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ return globalMw;
330
+ }
331
+
332
+ // A middleware expression is an auth guard if it is `basicAuth ..` or a project function summarised as one.
333
+ function middlewareGuard(ctx, mod, expr) {
334
+ const sp = spine(expr);
335
+ if (!sp || !sp.head) return { opaque: true };
336
+ const q = ctx.qualify(mod, sp.head);
337
+ if (q && GUARD_PRIMITIVES.has(q.name)) return { kind: 'basic', via: q.name };
338
+ if (q && q.qid) {
339
+ const fn = ctx.fnFor(q);
340
+ const gs = guardSummary(ctx, fn);
341
+ if (gs.auth) return { kind: gs.kinds[0] || 'token', via: q.name, role: gs.role, csrf: gs.csrf };
342
+ return { notGuard: true, via: q.name };
343
+ }
344
+ return { opaque: true };
345
+ }
346
+
347
+ // ── WAI ────────────────────────────────────────────────────────────────────
348
+ function waiRoutes(ctx, add) {
349
+ for (const mod of ctx.modules) {
350
+ for (const fun of mod.groups.funs) {
351
+ for (const clause of fun.clauses) {
352
+ for (const n of walkAst(clause.rhs.body)) {
353
+ if (n.t !== 'case') continue;
354
+ const scrutText = JSON.stringify(n.scrut);
355
+ if (!/pathInfo|rawPathInfo|requestMethod/.test(scrutText)) continue;
356
+ const hasMethod = /requestMethod/.test(scrutText);
357
+ const hasPath = /pathInfo|rawPathInfo/.test(scrutText);
358
+ const isTuple = unparen(n.scrut) && unparen(n.scrut).t === 'tuple';
359
+ for (const alt of n.alts || []) {
360
+ const pat = alt.pat;
361
+ let method = 'ANY', segs = null;
362
+ const patList = isTuple && pat.t === 'ptuple' ? pat.items : [pat];
363
+ const mPat = hasMethod ? patList[0] : null;
364
+ const pPat = hasPath ? (isTuple ? patList[hasMethod ? 1 : 0] : patList[0]) : null;
365
+ if (mPat && mPat.t === 'plit') method = String(mPat.v).toUpperCase();
366
+ else if (mPat && mPat.t === 'pwild') method = 'ANY';
367
+ if (pPat && pPat.t === 'plist') segs = pPat.items.map((p) => (p.t === 'plit' ? String(p.v) : ':param'));
368
+ else if (pPat && pPat.t === 'pwild') segs = null;
369
+ if (!segs && !(mPat && mPat.t === 'plit')) continue; // a pure fallback alternative is not a route
370
+ add({ framework: 'wai', method, path: segs ? `/${segs.join('/')}` : '<any>', file: mod.file, line: alt.line || fun.line, mod, handlerAst: alt.rhs.body, owner: `${mod.name}.${fun.name}`, routerFn: `${mod.name}.${fun.name}` });
371
+ }
372
+ }
373
+ }
374
+ }
375
+ }
376
+ }
377
+
378
+ // ── Servant ─────────────────────────────────────────────────────────────────
379
+ function servantTypeRoutes(ctx) {
380
+ const out = [];
381
+ for (const mod of ctx.modules) {
382
+ const T = mod.parse.tokens;
383
+ for (const td of mod.parse.typeDecls || []) {
384
+ const slice = T.slice(td.a, td.b);
385
+ const hasArrow = slice.some((t) => t.t === 'op' && t.v === ':>');
386
+ const hasVerb = slice.some((t) => t.t === 'con' && /^(?:Get|Post|Put|Delete|Patch)(?:NoContent)?$/.test(t.v));
387
+ if (!hasArrow || !hasVerb) continue;
388
+ const eq = slice.findIndex((t) => t.t === 'rop' && t.v === '=');
389
+ if (eq < 0) continue;
390
+ const body = slice.slice(eq + 1);
391
+ for (const alt of expandServant(body)) out.push({ mod, apiName: td.name, line: td.line, ...alt });
392
+ }
393
+ }
394
+ return out;
395
+ }
396
+
397
+ function splitTop(tokens, sep) {
398
+ const parts = [[]]; let depth = 0;
399
+ for (const t of tokens) {
400
+ if (t.t === 'sp' && (t.v === '(' || t.v === '[')) depth++;
401
+ if (t.t === 'sp' && (t.v === ')' || t.v === ']')) depth--;
402
+ if (depth === 0 && t.t === 'op' && t.v === sep) { parts.push([]); continue; }
403
+ parts[parts.length - 1].push(t);
404
+ }
405
+ return parts;
406
+ }
407
+
408
+ function expandServant(tokens) {
409
+ const alts = splitTop(tokens, ':<|>').filter((p) => p.length);
410
+ const out = [];
411
+ for (const alt of alts) {
412
+ const chain = splitTop(alt, ':>').filter((p) => p.length);
413
+ out.push(...expandChain(chain, { path: [], auth: null, params: [], captures: [], role: false, order: [] }));
414
+ }
415
+ return out;
416
+ }
417
+
418
+ function expandChain(chain, acc) {
419
+ if (!chain.length) return [];
420
+ const [head, ...rest] = chain;
421
+ const a = { path: [...acc.path], auth: acc.auth, params: [...acc.params], captures: [...acc.captures], role: acc.role, order: [...(acc.order || [])] };
422
+ const first = head[0];
423
+ if (head.length === 1 && first.t === 'str') { a.path.push(first.v); return rest.length ? expandChain(rest, a) : [finishServant(a, head)]; }
424
+ // a parenthesised group of alternatives at the tail distributes the prefix over each alternative
425
+ if (head.length >= 2 && head[0].t === 'sp' && head[0].v === '(' && !rest.length) {
426
+ const inner = head.slice(1, -1);
427
+ return splitTop(inner, ':<|>').filter((p) => p.length).flatMap((alt) => expandChain(splitTop(alt, ':>').filter((p) => p.length), a));
428
+ }
429
+ const name = first.t === 'con' ? first.v : null;
430
+ if (name === 'Capture' || name === 'Capture\'') { const nm = head.find((t) => t.t === 'str'); a.path.push(`:${nm ? nm.v : 'param'}`); a.captures.push(nm ? nm.v : 'param'); a.order.push(`capture:${nm ? nm.v : 'param'}`); }
431
+ else if (name === 'QueryParam' || name === 'QueryParam\'' || name === 'QueryParams') { const nm = head.find((t) => t.t === 'str'); a.params.push(nm ? nm.v : '?'); a.order.push(`query:${nm ? nm.v : '?'}`); }
432
+ else if (name === 'ReqBody') a.order.push('body');
433
+ else if (name === 'BasicAuth') { a.auth = { kind: 'basic', tag: 'BasicAuth' }; a.order.push('auth'); }
434
+ else if (name === 'AuthProtect') { const nm = head.find((t) => t.t === 'str'); a.auth = { kind: 'token', tag: nm ? nm.v : 'AuthProtect' }; a.order.push('auth'); if (nm && /admin|role|perm/i.test(nm.v)) a.role = true; }
435
+ else if (name === 'Auth') { a.auth = { kind: 'token', tag: 'Auth' }; a.order.push('auth'); }
436
+ else if (name && /^(?:Get|Post|Put|Delete|Patch)(?:NoContent)?$/.test(name)) return [finishServant(a, head)];
437
+ return rest.length ? expandChain(rest, a) : [finishServant(a, head)];
438
+ }
439
+
440
+ function finishServant(a, head) {
441
+ const verb = head.find((t) => t.t === 'con' && /^(?:Get|Post|Put|Delete|Patch)(?:NoContent)?$/.test(t.v));
442
+ return { method: verb ? verb.v.replace(/NoContent$/, '').toUpperCase() : 'ANY', path: `/${a.path.join('/')}`, auth: a.auth, params: a.params, captures: a.captures, role: a.role, order: a.order };
443
+ }
444
+
445
+ function servantHandlers(ctx, mod, apiName) {
446
+ // `server :: Server API` ... `server = h1 :<|> h2` (flattened left to right)
447
+ const sigFor = (name) => (mod.sigs.get(name) || {}).text || '';
448
+ for (const fun of mod.groups.funs) {
449
+ const text = sigFor(fun.name);
450
+ if (!new RegExp(`\\b${apiName}\\b`).test(text) && !/\bServer\b|\bServerT\b/.test(text)) continue;
451
+ const body = fun.clauses[0] && fun.clauses[0].rhs.body;
452
+ const flat = [];
453
+ const go = (e) => { e = unparen(e); if (e && e.t === 'op' && e.op === ':<|>') { go(e.l); go(e.r); } else flat.push(e); };
454
+ if (body) go(body);
455
+ if (flat.length > 0) return { fnName: fun.name, handlers: flat, fun };
456
+ }
457
+ return null;
458
+ }
459
+
460
+ // ── Yesod ──────────────────────────────────────────────────────────────────
461
+ function yesodRoutes(ctx) {
462
+ const out = [];
463
+ for (const mod of ctx.modules) {
464
+ const text = ctx.files[mod.file];
465
+ if (typeof text !== 'string') continue;
466
+ const re = /\[parseRoutes\|([\s\S]*?)\|\]/g;
467
+ let m;
468
+ while ((m = re.exec(text))) {
469
+ const base = text.slice(0, m.index).split('\n').length;
470
+ const lines = m[1].split('\n');
471
+ lines.forEach((raw, i) => {
472
+ const line = raw.trim();
473
+ if (!line || line.startsWith('--')) return;
474
+ const parts = line.split(/\s+/);
475
+ if (!parts[0].startsWith('/') || parts.length < 2) return;
476
+ const res = parts[1];
477
+ const methods = parts.slice(2).filter((x) => /^[A-Z]+$/.test(x));
478
+ out.push({ mod, path: parts[0].replace(/#\w+/g, ':param'), resource: res, methods, line: base + i + 1 });
479
+ });
480
+ }
481
+ }
482
+ return out;
483
+ }
484
+
485
+ function yesodAuthorization(ctx) {
486
+ // isAuthorized clauses by route constructor; a catch-all clause is the default
487
+ const byRoute = new Map(); let fallback = null;
488
+ for (const mod of ctx.modules) for (const inst of mod.parse.instances || []) {
489
+ if (inst.name !== 'Yesod') continue;
490
+ const funs = (inst.decls ? Object.values(groupLike(inst.decls)) : []);
491
+ void funs;
492
+ }
493
+ return { byRoute, fallback };
494
+ }
495
+ function groupLike(decls) { const m = {}; for (const d of decls) if (d.t === 'clause') (m[d.name] ||= []).push(d); return m; }
496
+
497
+ // ── main ───────────────────────────────────────────────────────────────────
498
+ /**
499
+ * @param {Record<string,string>} files path -> source
500
+ * @param {{packageVersions?: Record<string,string>}} [opts]
501
+ */
502
+ export function analyzeHaskellWeb(files, opts = {}) {
503
+ const hs = {};
504
+ for (const [f, t] of Object.entries(files || {})) if (/\.hs$/i.test(f) && typeof t === 'string') hs[f] = t;
505
+ const result = { routes: [], findings: [], middleware: [], frameworks: [], coverage: {}, gaps: [], modelVersion: HS_WEB_MODEL_VERSION, rulesetVersion: HS_WEB_RULES_VERSION, versionStatus: {} };
506
+ if (!Object.keys(hs).length) return result;
507
+ const ctx = new Ctx(hs);
508
+ const pending = [];
509
+ const add = (r) => pending.push(r);
510
+
511
+ // frameworks present (by import)
512
+ const fw = new Set();
513
+ for (const mod of ctx.modules) for (const imp of mod.imports) for (const [name, def] of Object.entries(HS_WEB_FRAMEWORKS)) if (def.modules.some((m) => imp.module === m || imp.module.startsWith(`${m}.`))) fw.add(name);
514
+ result.frameworks = [...fw].sort();
515
+ for (const name of fw) {
516
+ const v = opts.packageVersions && opts.packageVersions[HS_WEB_FRAMEWORKS[name].package];
517
+ result.versionStatus[name] = { package: HS_WEB_FRAMEWORKS[name].package, version: v || null, status: v ? (HS_WEB_FRAMEWORKS[name].tested.some((t) => v === t || v.startsWith(`${t}.`)) ? 'tested' : 'untested-version') : 'unknown-version', tested: HS_WEB_FRAMEWORKS[name].tested };
518
+ }
519
+
520
+ // 1. Scotty
521
+ const scottyMw = fw.has('scotty') ? scottyRoutes(ctx, add) : [];
522
+ let scottyGlobal = null;
523
+ for (const mw of scottyMw) {
524
+ const g = middlewareGuard(ctx, mw.mod, mw.expr);
525
+ result.middleware.push({ framework: 'scotty', file: mw.file, line: mw.line, status: g.kind ? 'auth-guard' : (g.opaque ? 'opaque' : 'not-an-auth-guard'), via: g.via || null });
526
+ if (g.kind && !scottyGlobal) scottyGlobal = { kind: g.kind, via: g.via, role: g.role, file: mw.file, line: mw.line };
527
+ if (g.opaque) ctx.gaps.push({ kind: 'opaque-middleware', detail: 'a Scotty middleware could not be resolved to a project function, so it earns no protection credit', file: mw.file, line: mw.line });
528
+ }
529
+ // 2. WAI
530
+ if (fw.has('wai')) waiRoutes(ctx, add);
531
+ // WAI global middleware: `run port (authMw router)` protects exactly the routers named inside that expression
532
+ const waiGlobals = [];
533
+ if (fw.has('wai')) {
534
+ for (const mod of ctx.modules) for (const fun of mod.groups.funs) for (const n of walkAst(fun.clauses[0] && fun.clauses[0].rhs.body)) {
535
+ const sp = n.t === 'app' || n.t === 'op' ? spine(n) : null;
536
+ if (!sp || !sp.head) continue;
537
+ const q = ctx.qualify(mod, sp.head);
538
+ if (!(q && /^Network\.Wai\.Handler\.Warp\.(?:run|runSettings)$/.test(q.name) && sp.args.length)) continue;
539
+ const appArg = sp.args[sp.args.length - 1];
540
+ const asp = spine(appArg);
541
+ const routers = new Set();
542
+ for (const x of walkAst(appArg)) if (x.t === 'var') routers.add(x.name);
543
+ if (asp && asp.head && asp.args.length) {
544
+ const g = middlewareGuard(ctx, mod, { t: 'var', name: asp.head.name, qual: asp.head.qual });
545
+ result.middleware.push({ framework: 'wai', file: mod.file, line: asp.head.line || fun.line, status: g.kind ? 'auth-guard' : (g.opaque ? 'opaque' : 'not-an-auth-guard'), via: g.via || null });
546
+ if (g.kind) waiGlobals.push({ kind: g.kind, via: g.via, role: g.role, file: mod.file, routers });
547
+ else if (g.opaque) ctx.gaps.push({ kind: 'opaque-middleware', detail: `the WAI middleware ${asp.head.name} could not be resolved to a project function, so it earns no protection credit`, file: mod.file, line: asp.head.line || fun.line });
548
+ } else {
549
+ result.middleware.push({ framework: 'wai', file: mod.file, line: sp.head.line || fun.line, status: 'none-installed', via: null });
550
+ }
551
+ }
552
+ }
553
+
554
+ // 3. Servant (type-level)
555
+ const servantEndpoints = fw.has('servant') ? servantTypeRoutes(ctx) : [];
556
+ const servantByMod = new Map();
557
+ servantEndpoints.forEach((e, i) => { if (!servantByMod.has(e.mod)) servantByMod.set(e.mod, []); servantByMod.get(e.mod).push({ ...e, idx: i }); });
558
+ for (const [mod, list] of servantByMod) {
559
+ const apiName = list[0].apiName;
560
+ const h = servantHandlers(ctx, mod, apiName);
561
+ list.forEach((e, i) => {
562
+ const hAst = h && h.handlers.length === list.length ? h.handlers[i] : null;
563
+ if (h && h.handlers.length !== list.length) ctx.gaps.push({ kind: 'servant-handler-arity', detail: `${apiName} has ${list.length} endpoints but ${h.fnName} lists ${h.handlers.length} handlers; handlers are not paired`, file: mod.file, line: e.line });
564
+ add({ framework: 'servant', method: e.method, path: e.path, file: mod.file, line: e.line, mod, handlerAst: hAst, servant: e, owner: apiName, whereDecls: h && h.fun && h.fun.clauses[0] && h.fun.clauses[0].rhs.where });
565
+ });
566
+ }
567
+
568
+ // 4. Yesod
569
+ const yroutes = fw.has('yesod') ? yesodRoutes(ctx) : [];
570
+ let yesodAuthz = null;
571
+ if (fw.has('yesod')) yesodAuthz = collectYesodAuthz(ctx);
572
+ for (const yr of yroutes) {
573
+ const methods = yr.methods.length ? yr.methods : ['ANY'];
574
+ for (const method of methods) {
575
+ const hname = method === 'ANY' ? `handle${yr.resource}` : `${method.toLowerCase()}${yr.resource}`;
576
+ add({ framework: 'yesod', method, path: yr.path, file: yr.mod.file, line: yr.line, mod: yr.mod, handlerName: hname, resource: yr.resource, owner: yr.resource });
577
+ }
578
+ }
579
+
580
+ // ── resolve handlers and analyse ─────────────────────────────────────────
581
+ for (const p of pending) {
582
+ const R = newRoute({ framework: p.framework, method: p.method, path: p.path, file: p.file, line: p.line, owner: p.owner });
583
+ if (p.dynamicPath) { R.auth = { status: 'unknown', kind: null, evidence: [], reason: 'route path is not a literal' }; ctx.gaps.push({ kind: 'dynamic-route-path', detail: `${p.framework} route path is computed at runtime`, file: p.file, line: p.line }); R.handler = { kind: 'unknown' }; result.routes.push(R); continue; }
584
+ let handlerFn = null; let handlerName = null; let paramNames = [];
585
+ const paramsOf = (clause) => ((clause && clause.pats) || []).map((x) => (x && x.t === 'pvar' ? x.name : null));
586
+ const topFun = (mod, name) => mod.groups.funs.find((f) => f.name === name);
587
+ if (p.handlerName) {
588
+ const def = p.mod.defs.get(p.handlerName) || [...ctx.modules].map((m) => m.defs.get(p.handlerName)).find(Boolean);
589
+ handlerName = p.handlerName;
590
+ if (def) {
591
+ handlerFn = ctx.fnByQid.get(def.qid) || null;
592
+ const f = topFun(p.mod, p.handlerName);
593
+ if (f && f.clauses[0]) paramNames = paramsOf(f.clauses[0]);
594
+ }
595
+ } else if (p.handlerAst) {
596
+ const sp = spine(p.handlerAst);
597
+ if (p.handlerAst.t === 'var' || (sp && sp.head && sp.args.length === 0)) {
598
+ const head = sp ? sp.head : p.handlerAst;
599
+ // a handler bound in the enclosing `where` (Servant servers usually are)
600
+ const local = (p.whereDecls || []).find((d) => d.name === head.name && !head.qual);
601
+ if (local && local.clause && local.clause.rhs && local.clause.rhs.body && !(local.clause.rhs.guards && local.clause.rhs.guards.length)) {
602
+ try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, local.clause.rhs.body, `${p.framework}-${head.name}`, local.line || p.line); handlerName = head.name; paramNames = paramsOf(local.clause); } catch { handlerFn = null; }
603
+ } else {
604
+ const q = ctx.qualify(p.mod, head);
605
+ if (q && q.qid) { handlerFn = ctx.fnFor(q); handlerName = q.name; const f = topFun(p.mod, head.name); if (f && f.clauses[0]) paramNames = paramsOf(f.clauses[0]); }
606
+ else if (q) handlerName = q.name;
607
+ }
608
+ } else {
609
+ try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, p.handlerAst, `${p.framework}-${p.line}`, p.line); handlerName = '<inline>'; } catch { handlerFn = null; }
610
+ }
611
+ }
612
+ R.handler = handlerFn ? { kind: handlerName === '<inline>' ? 'inline' : 'named', name: handlerName } : { kind: 'unknown', name: handlerName };
613
+ const o = {};
614
+ // WAI: a middleware protects only the routers named in the `run` expression that installs it
615
+ if (p.framework === 'scotty' && scottyGlobal) { o.globalAuth = scottyGlobal; if (scottyGlobal.role) o.globalRole = true; }
616
+ if (p.framework === 'wai') {
617
+ const g = waiGlobals.find((w) => w.file === p.file && w.routers.has((p.routerFn || '').split('.').pop()));
618
+ if (g) { o.globalAuth = g; if (g.role) o.globalRole = true; }
619
+ }
620
+ if (p.framework === 'servant' && p.servant.auth) {
621
+ o.typeAuth = p.servant.auth; if (p.servant.role) o.typeRole = true;
622
+ o.principalVars = []; o.idVars = [];
623
+ (p.servant.order || []).forEach((kind, i) => {
624
+ const nm = paramNames[i]; if (!nm) return;
625
+ if (kind === 'auth') o.principalVars.push(nm);
626
+ else if (/^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm);
627
+ });
628
+ } else if (p.framework === 'servant') {
629
+ o.idVars = [];
630
+ (p.servant.order || []).forEach((kind, i) => { const nm = paramNames[i]; if (nm && /^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm); });
631
+ }
632
+ if (p.framework === 'yesod') {
633
+ o.idVars = paramNames.filter(Boolean);
634
+ if (yesodAuthz) {
635
+ const z = yesodAuthz.forRoute(p.resource);
636
+ if (z && z.status === 'authenticated') o.globalAuth = { kind: 'session', via: 'isAuthorized', file: z.file, line: z.line };
637
+ if (z && z.role) o.globalRole = true;
638
+ }
639
+ }
640
+ analyzeRoute(ctx, R, handlerFn, o);
641
+ if (p.framework === 'servant') { R.params = [...p.servant.captures, ...p.servant.params]; R.servant = { captures: p.servant.captures }; }
642
+ if (p.framework === 'yesod') R.yesodExplicitPublic = !!(yesodAuthz && yesodAuthz.forRoute(p.resource) && yesodAuthz.forRoute(p.resource).status === 'public');
643
+ result.routes.push(R);
644
+ }
645
+
646
+ result.yesod = yesodAuthz ? yesodAuthz.summary : null;
647
+ emitFindings(ctx, result, yesodAuthz);
648
+
649
+ // coverage reconciliation: every route is either analysed or unknown
650
+ const analysed = result.routes.filter((r) => r.handler && r.handler.kind !== 'unknown').length;
651
+ result.coverage = { routes: result.routes.length, analysed, unknown: result.routes.length - analysed, gaps: ctx.gaps.length };
652
+ result.gaps = ctx.gaps;
653
+ for (const r of result.routes) { delete r.facts; if (r.credentialKinds) r.credentialKinds = [...r.credentialKinds]; }
654
+ return result;
655
+ }
656
+
657
+ function collectYesodAuthz(ctx) {
658
+ const rules = new Map();
659
+ let fallback = null;
660
+ let yesodMiddlewareOverride = null;
661
+ const summary = { isAuthorized: false, defaultStatus: null, middlewareOverride: null };
662
+ for (const mod of ctx.modules) for (const inst of mod.parse.instances || []) {
663
+ if (inst.name !== 'Yesod') continue;
664
+ summary.isAuthorized = true;
665
+ const clauses = (inst.decls || []).filter((d) => d.t === 'clause' && d.name === 'isAuthorized');
666
+ for (const dcl of clauses) {
667
+ const c = dcl.clause || dcl;
668
+ const first = c.pats && c.pats[0];
669
+ const body = c.rhs && c.rhs.body;
670
+ const text = JSON.stringify(body || {});
671
+ let status = 'unknown';
672
+ let role = false;
673
+ const sp = body ? (() => { const bodyIsDo = body.t === 'do'; return bodyIsDo; })() : false;
674
+ void sp;
675
+ const names = [];
676
+ for (const n of walkAst(body)) if (n.t === 'var') names.push(n.name);
677
+ if (names.some((n) => /^(?:requireAuthId|requireAuth|maybeAuthId|maybeAuth)$/.test(n)) || names.some((n) => { const q = ctx.qualify(mod, { name: n, qual: null }); return q && q.qid && guardSummary(ctx, ctx.fnFor(q)).auth; })) status = 'authenticated';
678
+ else if (/"Authorized"/.test(text) || names.includes('Authorized')) status = 'public';
679
+ if (names.some((n) => ROLE_CALL.test(n))) role = true;
680
+ const rec = { status, role, file: mod.file, line: dcl.line || c.line };
681
+ if (first && first.t === 'pcon') rules.set(first.con.replace(/^.*\./, ''), rec);
682
+ else fallback = rec;
683
+ }
684
+ for (const d of inst.decls || []) if (d.t === 'clause' && d.name === 'yesodMiddleware') {
685
+ const t = JSON.stringify((d.clause && d.clause.rhs && d.clause.rhs.body) || {});
686
+ yesodMiddlewareOverride = { csrf: /csrf/i.test(t), file: mod.file, line: d.line };
687
+ }
688
+ }
689
+ summary.defaultStatus = fallback ? fallback.status : null;
690
+ summary.middlewareOverride = yesodMiddlewareOverride;
691
+ return { forRoute: (resource) => rules.get(resource) || fallback, summary, middlewareOverride: yesodMiddlewareOverride, rules };
692
+ }
693
+
694
+ function emitFindings(ctx, result, yesodAuthz) {
695
+ const out = result.findings;
696
+ const emit = (rule, R, extra = {}) => {
697
+ const r = WEB_RULES[rule];
698
+ const text = ctx.files[R.file] || '';
699
+ const line = extra.line || R.line;
700
+ // A framework version the model was not written against still gets analysed, but says so.
701
+ const vs = result.versionStatus[R.framework];
702
+ const untested = vs && vs.status !== 'tested';
703
+ if (untested) extra = { ...extra, confidence: (extra.confidence ?? 0.75) * 0.8, uncertainty: [...(extra.uncertainty || []), { kind: 'untested-model', detail: `${vs.package} ${vs.version || '(version unknown)'} is not in the tested set (${vs.tested.join(', ')}); the route model is applied best-effort` }] };
704
+ const snip = (text.split('\n')[line - 1] || '').trim().slice(0, 240);
705
+ out.push({
706
+ id: `hs-web:${rule}:${R.file}:${line}:${R.method}:${R.path}`, severity: extra.severity || r.severity, file: R.file, line, vuln: r.vuln, cwe: r.cwe,
707
+ description: `${r.vuln}: ${R.method} ${R.path} (${R.framework})${extra.detail ? ` - ${extra.detail}` : ''}`,
708
+ remediation: r.remediation, parser: 'HS-WEB', family: r.family, language: 'haskell', capability: 'route-inventory', analysisKind: 'application', evidenceKind: 'source',
709
+ originalLocation: { file: R.file, line, column: 0 }, snippet: snip, confidence: extra.confidence ?? 0.75, rule, ruleVersion: 1, rulesetVersion: HS_WEB_RULES_VERSION, modelVersion: HS_WEB_MODEL_VERSION,
710
+ siteKey: `${R.method} ${R.path}`, route: { framework: R.framework, method: R.method, path: R.path, handler: R.handler }, ...(extra.evidence ? { evidence: extra.evidence } : {}),
711
+ ...(extra.uncertainty ? { uncertainty: extra.uncertainty } : {}),
712
+ });
713
+ };
714
+ for (const R of result.routes) {
715
+ if (!R.handler || R.handler.kind === 'unknown') continue;
716
+ const writes = WRITE_METHODS.has(R.method) || R.method === 'ANY';
717
+ const privileged = ADMIN_PATH.test(R.path) || /admin/i.test(R.handler.name || '');
718
+ if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
719
+ else if (R.auth.status === 'none' && (writes || privileged) && R.sensitive !== false) {
720
+ if (!(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: R.method === 'ANY' ? 'any method' : 'state-changing method' });
721
+ }
722
+ if ((R.auth.status === 'authenticated') && privileged && R.role.status !== 'present') emit('hs-route-bfla', R, { detail: 'authenticated but no role/permission check was found', evidence: { auth: R.auth.evidence } });
723
+ if (R.auth.status === 'authenticated' && R.idLookups && R.idLookups.length && !R.ownership) {
724
+ emit('hs-route-bola', R, { detail: 'the id from the request reaches a lookup with no ownership or tenant check', line: R.idLookups[0].line, evidence: { lookup: R.idLookups[0].call.callee } });
725
+ }
726
+ const cookie = R.credentialKinds && (R.credentialKinds.has('cookie') || R.credentialKinds.has('session'));
727
+ if (writes && R.auth.status === 'authenticated' && cookie && !R.csrf && R.framework !== 'yesod') emit('hs-route-csrf', R, { detail: 'cookie/session credential with no CSRF token check' });
728
+ }
729
+ if (yesodAuthz && yesodAuthz.middlewareOverride && !yesodAuthz.middlewareOverride.csrf) {
730
+ const o = yesodAuthz.middlewareOverride;
731
+ out.push({
732
+ id: `hs-web:hs-yesod-csrf-disabled:${o.file}:${o.line}`, severity: 'medium', file: o.file, line: o.line, vuln: WEB_RULES['hs-yesod-csrf-disabled'].vuln, cwe: 'CWE-352',
733
+ description: 'yesodMiddleware is overridden and does not include a CSRF middleware', remediation: WEB_RULES['hs-yesod-csrf-disabled'].remediation, parser: 'HS-WEB', family: 'csrf', language: 'haskell',
734
+ capability: 'route-inventory', analysisKind: 'application', evidenceKind: 'source', originalLocation: { file: o.file, line: o.line, column: 0 }, snippet: '', confidence: 0.7, rule: 'hs-yesod-csrf-disabled', ruleVersion: 1, rulesetVersion: HS_WEB_RULES_VERSION, modelVersion: HS_WEB_MODEL_VERSION,
735
+ });
736
+ }
737
+ void modelStatus;
738
+ }
739
+
740
+ // The scan engine's route record (see scanRoutes) for each analysed Haskell route.
741
+ export function toEngineRoutes(web) {
742
+ const FW = { scotty: 'Scotty', wai: 'WAI', servant: 'Servant', yesod: 'Yesod' };
743
+ return web.routes.map((R) => ({
744
+ method: R.method, path: R.path, framework: FW[R.framework] || R.framework, file: R.file, line: R.line,
745
+ hasAuth: R.auth.status === 'authenticated', authStatus: R.auth.status, hasFileUpload: !!R.uploads,
746
+ params: Array.isArray(R.params) ? R.params : [], classifications: [], classifiedFields: {},
747
+ language: 'haskell', handler: R.handler ? { kind: R.handler.kind, name: R.handler.name || null } : null,
748
+ }));
749
+ }