@clear-capabilities/agentic-security-scanner 0.195.27 → 0.195.29

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 (38) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/agentic-security.js +41 -4
  3. package/dist/5856.index.js +634 -0
  4. package/dist/7033.index.js +74 -14
  5. package/dist/9220.index.js +24 -1
  6. package/dist/agentic-security.mjs +9 -9
  7. package/dist/agentic-security.mjs.sha256 +1 -1
  8. package/dist/{90ebba38d8860ec77376.json → c06a1b4c20155c06fb48.json} +4 -3
  9. package/package.json +4 -3
  10. package/src/dataflow/catalog.js +42 -1
  11. package/src/engine.js +91 -11
  12. package/src/language/CLAUDE.md +7 -1
  13. package/src/language/assurance.js +53 -2
  14. package/src/language/haskell-budget-state.js +85 -0
  15. package/src/language/haskell-coverage.js +230 -0
  16. package/src/language/haskell-guards.js +26 -5
  17. package/src/language/haskell-project-models.js +107 -0
  18. package/src/language/nix-eval-isolation.js +74 -14
  19. package/src/llm-validator/bounded-context.js +193 -0
  20. package/src/llm-validator/claim-groups.js +105 -0
  21. package/src/llm-validator/disposition.js +264 -0
  22. package/src/llm-validator/index.js +52 -26
  23. package/src/pipeline/evidence-record.js +112 -0
  24. package/src/pipeline/scan-health.js +12 -1
  25. package/src/posture/artifact-registry.js +2 -0
  26. package/src/posture/drift.js +19 -1
  27. package/src/posture/reachability-filter.js +3 -3
  28. package/src/posture/suppressions.js +21 -0
  29. package/src/posture/watch-mode.js +22 -1
  30. package/src/report/disposition-view.js +90 -0
  31. package/src/report/index.js +47 -0
  32. package/src/runScan.js +60 -6
  33. package/src/sast/oidc-trust.js +74 -0
  34. package/src/sast/pipeline.js +14 -1
  35. package/src/sast/workflow-flow.js +272 -0
  36. package/src/sca/basis.js +88 -0
  37. package/src/scope/flags.js +55 -0
  38. package/src/scope/inventory.js +241 -0
@@ -160,7 +160,7 @@ The `unseen` split (unseen-v3) is a **DEVELOPMENT set since 2026-10-26**: its fa
160
160
 
161
161
  **The detection suite scores `actionable` findings (since 0.195.9):** a finding at `info`, or one the engine PROVED clean or infeasible (`proof.verdict` `proven-*`: a catalog sanitizer of the sink's family on the path, a dominating guard), is not a detection, the same rule `bench/language-support/lib.mjs` `actionable` already applied elsewhere. Before, `renderHtml (toHtml name)` into a Scotty `html` sink was scored a false positive although the proof gate had labelled it proven-clean. **Disclosure:** while the real-code precision work of 0.195.9 was being investigated, two frozen-holdout path-traversal sources (shapes s0 and s1, both a parameter joined under a fixed directory) were printed and read; no rule was changed to them (both are still findings), but the holdout has been seen by the person who changed the path-wrapper policy, and a reader weighing the holdout's independence should know that.
162
162
 
163
- Rules added for the unseen-v5 shapes, pinned both ways in `test/haskell/haskell-unseen-v5-shapes.test.js`: a Servant handler pairs the API's reads with its LAST parameters (`orderServer conn ref`; `haskell-ir.js` `servantEndpointParams` paired from the front and annotated the closure parameter); a `Set-Cookie` header handed to `setHeader`/`addHeader` is judged like a hand-written tuple; a project helper the IR inlined at its call site (`hs.inlinedFrom`) reports a fast password hash or a credential log once, in the helper (`markInlined`, `inlineCtx`, the filter before the return); mwc-random and splitmix are predictable generators (`System.Random.MWC` in `HS_RULE_APIS` so the IR qualifies the names); a function that MAKES a key (`newKey`, `generateApiKey`) is security-shaped, and `code`/`pin`/`otp` is a security value in a function that talks about a reset or a verification (`CONTEXT_CODE_VAR`, `CODE_CONTEXT`); an authentication endpoint (`AUTH_ENDPOINT_PATH`/`_NAME`) is never missing authentication; `X-Session`, `X-Token` and similar project header names are credentials (`CREDENTIAL_HEADER`); a yes/no answer about a credential (`accepted <- checkCredentials attempt`, a comparison, a `logical`, a helper whose returns are such: `returnsYesNo`) carries no credential text, a record built from one logged through another field (`attemptUser attempt`, a `member` of it) is that field, the record logged whole is the credential, and `lastFour`/`takeEnd`/`fingerprint` are masking helpers by name; `pushLogStr(Ln)` is a log sink. In `haskell-guards.js`: a CWE-918 flow whose URL starts from a literal (or a constant bound to one) naming the scheme, the host and a `/`, or whose request is built by http-conduit's modifiers over such a base, is lowered to `info` with `guardNote.kind: fixed-host-request` (`fixedHostRequest`; a record update that sets a host field, or a literal that stops inside the host, is not); `judgeFinding` locates the sink in the CALLEE when the finding was found from a caller (the canonicalise-and-prefix guard in `persistDraft` refutes the flow found from `saveDraft`), and the XSS escaper judgement knows the catalog's HTML escapers and a project helper that uses the parameter receiving the tainted value only under an escaper. Nix (`test/nix/nix-development-shapes.test.js`): `AmbientCapabilities` as one string, `Defaults !authenticate`, a `*File` option handed a `writeText` of a literal (`nix-secret-plaintext`, exposure `store`), a TLS key path on any enabled service (`sslKey`, `sslServerKey`, `keyFile`, ...), and one script-taint finding per rule, line and origin class.
163
+ Rules added for the unseen-v5 shapes, pinned both ways in `test/haskell/haskell-unseen-v5-shapes.test.js`: a Servant handler pairs the API's reads with its LAST parameters (`orderServer conn ref`; `haskell-ir.js` `servantEndpointParams` paired from the front and annotated the closure parameter); a `Set-Cookie` header handed to `setHeader`/`addHeader` is judged like a hand-written tuple; a project helper the IR inlined at its call site (`hs.inlinedFrom`) reports a fast password hash or a credential log once, in the helper (`markInlined`, `inlineCtx`, the filter before the return); mwc-random and splitmix are predictable generators (`System.Random.MWC` in `HS_RULE_APIS` so the IR qualifies the names); a function that MAKES a key (`newKey`, `generateApiKey`) is security-shaped, and `code`/`pin`/`otp` is a security value in a function that talks about a reset or a verification (`CONTEXT_CODE_VAR`, `CODE_CONTEXT`); an authentication endpoint (`AUTH_ENDPOINT_PATH`/`_NAME`) is never missing authentication; `X-Session`, `X-Token` and similar project header names are credentials (`CREDENTIAL_HEADER`); a yes/no answer about a credential (`accepted <- checkCredentials attempt`, a comparison, a `logical`, a helper whose returns are such: `returnsYesNo`) carries no credential text, a record built from one logged through another field (`attemptUser attempt`, a `member` of it) is that field, the record logged whole is the credential, and `lastFour`/`takeEnd`/`fingerprint` are masking helpers by name; `pushLogStr(Ln)` is a log sink. In `haskell-guards.js`: a CWE-918 flow whose URL starts from a literal (or a constant bound to one) naming the scheme, the host and a `/`, or whose request is built by http-conduit's modifiers over such a base, is lowered to `info` with `guardNote.kind: fixed-host-request` (`fixedHostRequest`; a record update that sets a host field, or a literal that stops inside the host, is not); when the WHOLE destination (scheme, host and path) is the literal and the client reaches only the query, headers, body, method or auth (`destinationFixed`), the guard also records a `constant-input` refutation tied to the file's hash, so the disposition is `refuted` (a concatenation, a path setter, a manager or an unknown record field leaves it open: lowered only); `judgeFinding` locates the sink in the CALLEE when the finding was found from a caller (the canonicalise-and-prefix guard in `persistDraft` refutes the flow found from `saveDraft`), and the XSS escaper judgement knows the catalog's HTML escapers and a project helper that uses the parameter receiving the tainted value only under an escaper. Nix (`test/nix/nix-development-shapes.test.js`): `AmbientCapabilities` as one string, `Defaults !authenticate`, a `*File` option handed a `writeText` of a literal (`nix-secret-plaintext`, exposure `store`), a TLS key path on any enabled service (`sslKey`, `sslServerKey`, `keyFile`, ...), and one script-taint finding per rule, line and origin class.
164
164
 
165
165
  Rules added for the unseen-v4 shapes, each pinned both ways in `test/haskell/haskell-unseen-v4-shapes.test.js`: a value READ from the request inside a function (`param`, `strictRequestBody`, `requestHeaders`, any `REQUEST_PROVENANCE` source) is caller text for the partial-function, allocation and bounding rules whatever the function's parameters are; a presence test does not cover a partial call on a value SPLIT or parsed from the tested one (`SPLITTER`), while the same split of the same value does; a relational test on a count bounds the allocation it sizes; a `System.Random` value that reaches the result of a security-shaped function is predictable randomness; a project record parameter with a credential or card field logged whole (`show card`) is sensitive logging, through a masking helper by name it is not; wai-extra `requestSizeCheck` bounds the body read. In `haskell-web.js`: Servant closure arguments before the endpoint's values are paired from the end; a container lookup by the request id (`M.lookup`) is the object selection for BOLA only; BOLA without an authenticated principal needs either a WRITE to the selected object or OWNER EVIDENCE (`OWNER_COL`: a statement names an owner column its WHERE clause does not use; `OWNER_FIELD`: the record the handler works with has an owner field it never reads, where "works with" means another field of that record is read); a Scotty handler typed `ActionM` but registered elsewhere is a route with method `UNKNOWN` (ownership only, never a missing-auth finding); a Yesod `isAuthorized` clause that can answer `AuthenticationRequired` guards; a privileged path whose handler calls into unseen code is a lower-severity missing-auth finding, but a plain write with no visible operation is NOT (23 echo, upload-demo and health-check routes on four real projects). Taint: `Network.Wai.Parse.fileName` is an `upload` source; `Network.Wai.responseLBS`/`responseBuilder` are CWE-79 sinks judged by the header argument (`waiHtmlContext`: `text/html` keeps, another content type lowers to `info`, opaque headers keep with a stated uncertainty); the HTML-escaper judgement follows local bindings, and a definition with no value in it (a read) is the raw origin; a Shakespeare quasi-quote with interpolations is rewritten in place to an application of its interpolated expressions (`hamletInterpolations`, boundary kind `quasiquote-hamlet`, never opaque) so `#{preEscapedToHtml body}` is a sink site and `#{body}` is not. Two shapes stay as they were on purpose: `getArgs` reaching a shell is `operator-controlled` (info), and `fetch url = parseRequest url >>= httpNoBody` stays a finding (a URL wrapper is not an `api-contract`); unseen-v4 labels both the other way, and that is recorded, not decided.
166
166
 
@@ -203,3 +203,9 @@ at the use site that shares one of the names disables the expansion (it stays th
203
203
  the alias's call-graph edge is kept via `inlinedCalls`; arguments are lowered in the CALLER's module. Pinned both ways in
204
204
  `test/haskell/haskell-alias-taint.test.js`. Haskell verdict-flip cases live in `bench/mutation/cases-haskell.mjs`; the 8
205
205
  `bench/cve-replay/deep/hs-*-shape` entries are synthetic (self-authored, not from advisories).
206
+
207
+ ## Project models for Haskell wrappers (PRD FR 03)
208
+
209
+ `haskell-project-models.js` loads `.agentic-security/haskell-models.json` (schema `haskell-project-models/1`). A model declares a source, sink or sanitizer by module and function name and is accepted only with provenance (reviewer, date, reason), an argument position for a sink, an explicit family list for a sanitizer (`*` is refused), and a paired positive and negative test file that exists inside the project. A rejected model is a scan-health condition and has no effect. Accepted models are registered into the taint catalog for one scan (`registerProjectCatalogEntries`, tagged `source: 'project'`) and removed when `runFullScan` returns or throws. Each scan runs in its own owner scope (`scanOwner`, an AsyncLocalStorage in `dataflow/catalog.js`), so overlapping scans of different roots in one process never match each other's project entries.
210
+
211
+ `haskell-budget-state.js` reads operator parse budgets from `AGENTIC_SECURITY_HASKELL_PARSE_BUDGETS` (`name=value,...`, parser budget names only; anything else is refused as a condition), and writes `.agentic-security/haskell-budget.json` with the effective budgets and every remaining file plus a content digest. The next scan reports `budget.sinceLast` (completed, still remaining on the same inputs, still remaining after a change, gone, newly remaining). It records work left; it does not resume, because the assurance pass parses every eligible file on every scan.
@@ -23,6 +23,10 @@ import { deriveCppContext } from './haskell-cpp.js';
23
23
  import { createNixAdapter } from './nix-adapter.js';
24
24
  import { analyzeHaskellManifests } from './haskell-manifests.js';
25
25
  import { packageOfModule } from './haskell-models.js';
26
+ import { buildHaskellCoverage } from './haskell-coverage.js';
27
+ import { budgetRecord, loadBudgetState, saveBudgetState } from './haskell-budget-state.js';
28
+ import { DEFAULT_PARSE_BUDGETS } from './haskell-parser.js';
29
+ import { buildHaskellIR } from './haskell-ir.js';
26
30
 
27
31
  export const ASSURANCE_VERSION = 'language-assurance/1';
28
32
 
@@ -75,7 +79,9 @@ export async function assessLanguageAssurance(input = {}) {
75
79
  const DISCLOSED_KINDS = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert']);
76
80
  const disclosed = new Map(); // boundary kind -> {count, files:Set}
77
81
  const projectModules = new Set(); const importedModules = new Map(); // module -> files
82
+ const parsesByFile = new Map();
78
83
  const onParse = (file, parse) => {
84
+ parsesByFile.set(file, parse);
79
85
  if (parse && parse.module && parse.module.name) projectModules.add(parse.module.name);
80
86
  for (const i of (parse && parse.imports) || []) if (i && i.module) { const e = importedModules.get(i.module) || new Set(); e.add(file); importedModules.set(i.module, e); }
81
87
  for (const b of (parse && parse.boundaries) || []) {
@@ -86,7 +92,7 @@ export async function assessLanguageAssurance(input = {}) {
86
92
  disclosed.set(b.kind, e);
87
93
  }
88
94
  };
89
- const adapters = input.adapters || [createHaskellAdapter({ onParse, cpp: deriveCppContext(files) }), createNixAdapter()];
95
+ const adapters = input.adapters || [createHaskellAdapter({ onParse, cpp: deriveCppContext(files), budgets: input.parseBudgets || undefined }), createNixAdapter()];
90
96
  const run = await runLanguageAnalysis({ files, adapters, timeoutMs: input.timeoutMs || 0 });
91
97
  const reconcile = reconcileLanguageLedger(run.ledger, files);
92
98
  const base = languageHealth({ ledger: run.ledger, outcomes: run.outcomes });
@@ -136,6 +142,18 @@ export async function assessLanguageAssurance(input = {}) {
136
142
  if (ociFiles.length) limitations.push({ kind: 'container-image-scan', files: ociFiles.sort(), note: 'Nix-built images and declared OCI services are analysed as configuration only: image layers and base-image vulnerabilities are NOT scanned' });
137
143
  if (!reconcile.ok) conditions.push(`language ledger does not reconcile with the inputs: ${reconcile.errors[0]}`);
138
144
 
145
+ // Buck-built Haskell: its dependency graph is not resolved by this scanner, so version-based advisory matching has nothing to match.
146
+ // Stated as a coverage gap (PRD FR 08) rather than left looking like "no vulnerable dependencies".
147
+ const buckHaskell = Object.entries(files).filter(([f, t]) => /(?:^|\/)(?:BUCK|TARGETS)(?:\.v2)?$/.test(f) && typeof t === 'string' && /\bhaskell_(?:library|binary|test|ghci)\b|\bprebuilt_haskell_library\b/.test(t)).map(([f]) => f).sort();
148
+ if (present.has('haskell') && buckHaskell.length) conditions.push(`${buckHaskell.length} Buck target file(s) declare Haskell dependencies this scan does not resolve (${buckHaskell.slice(0, 3).join(', ')}): dependency advisories were not matched for them`);
149
+
150
+ // Declared project models (PRD FR 03): a rejected declaration means a wrapper the project meant to model is still opaque.
151
+ const pm = input.projectModels;
152
+ if (pm && pm.present) {
153
+ if (pm.rejected.length) conditions.push(`${pm.rejected.length} Haskell project model(s) were rejected and have no effect: ${pm.rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join('; ')}`);
154
+ if (pm.accepted.length) limitations.push({ kind: 'project-models', count: pm.accepted.length, ids: pm.accepted.map((m) => m.id).sort(), note: 'wrappers modelled by reviewed project declarations (.agentic-security/haskell-models.json); each carries provenance and a paired positive and negative test' });
155
+ }
156
+
139
157
  // 2. manifests
140
158
  if (manifests.length) {
141
159
  let m = null;
@@ -188,6 +206,39 @@ export async function assessLanguageAssurance(input = {}) {
188
206
  }
189
207
  }
190
208
 
209
+ // 5b. Haskell coverage accounting (FR 02): per-file stages, rates against the eligible denominator, rule families executed.
210
+ let haskellCoverage = null;
211
+ if (present.has('haskell')) {
212
+ try {
213
+ let ir = null;
214
+ if (input.deepEnabled !== false) {
215
+ try { const hs = Object.fromEntries(Object.entries(files).filter(([f]) => /\.hs$/i.test(f))); if (Object.keys(hs).length) ir = buildHaskellIR(hs); } catch { ir = null; }
216
+ }
217
+ haskellCoverage = buildHaskellCoverage({
218
+ files, parses: parsesByFile, ir, deepEnabled: input.deepEnabled !== false && !disabled.has('haskell:taint'),
219
+ ruleRuns: input.ruleRuns || null, sizeSkipped: input.sizeSkipped, policyExcluded: input.policyExcluded,
220
+ excludedPaths: run.ledger.excludedFiles, sourceMap: input.sourceMap || null,
221
+ });
222
+ // Effective parse budgets, operator overrides that were refused, and the persisted record of work left (haskell-budget-state.js).
223
+ const b = haskellCoverage.budget;
224
+ b.budgets = { ...DEFAULT_PARSE_BUDGETS, ...(input.parseBudgets || {}) };
225
+ if (Array.isArray(input.parseBudgetRejected) && input.parseBudgetRejected.length) {
226
+ b.rejectedOverrides = input.parseBudgetRejected;
227
+ conditions.push(`Haskell parse budget override refused: ${input.parseBudgetRejected.map((r) => `${r.entry} (${r.reason})`).join('; ')}`);
228
+ }
229
+ const budgetOf = (file) => { const p = parsesByFile.get(file); return p && p.budget ? { budgetName: p.budget.name } : {}; };
230
+ const remainingAll = (b.remainingAll || []).map((r) => ({ ...r, ...budgetOf(r.file) }));
231
+ const previous = loadBudgetState(input.scanRoot);
232
+ const { record, sinceLast } = budgetRecord({ remaining: remainingAll, completedFiles: b.completedFiles, budgets: b.budgets, files, previous });
233
+ b.remaining = b.remaining.map((r) => ({ ...r, ...budgetOf(r.file) }));
234
+ b.sinceLast = sinceLast;
235
+ if (record.remainingFiles || previous) b.recorded = saveBudgetState(input.scanRoot, record);
236
+ // Only a measured shortfall is a condition. A parse-only form is a stated limitation, not a gap in what was asked.
237
+ for (const r of haskellCoverage.capability.reasons) if (!/deep analysis is disabled/.test(r)) conditions.push(`Haskell coverage: ${r}`);
238
+ for (const l of haskellCoverage.capability.limitations) limitations.push({ kind: 'haskell-parse-only', note: l });
239
+ } catch (e) { conditions.push(`Haskell coverage accounting failed: ${String((e && e.message) || e).slice(0, 160)}`); }
240
+ }
241
+
191
242
  // 6. optional modes
192
243
  const optionalModes = {};
193
244
  for (const name of OPTIONAL_MODES) {
@@ -198,7 +249,7 @@ export async function assessLanguageAssurance(input = {}) {
198
249
 
199
250
  return {
200
251
  version: ASSURANCE_VERSION,
201
- languageCoverage: { totals: base.totals, byKind, conditions, limitations, capabilities, optionalModes },
252
+ languageCoverage: { totals: base.totals, byKind, conditions, limitations, capabilities, optionalModes, ...(haskellCoverage ? { haskell: haskellCoverage } : {}) },
202
253
  ledger: { reconciled: reconcile.ok, errors: reconcile.errors, byAnalyzer: run.ledger.byAnalyzer },
203
254
  outcomes: run.outcomes,
204
255
  retained: { findings: findings.length, findingsRef: findings },
@@ -0,0 +1,85 @@
1
+ // Haskell parse budgets and the record of work left when one is hit (PRD FR 02 "budget behavior and proof of completion").
2
+ //
3
+ // The parser stops a file at a named bound (bytes, tokens, nesting, steps, wall clock) and reports `budget_exceeded`; the file is
4
+ // not analysed and the capability stays incomplete. This module does three things around that:
5
+ //
6
+ // 1. Operator budgets. AGENTIC_SECURITY_HASKELL_PARSE_BUDGETS raises (or lowers) named bounds: `deadlineMs=20000,maxTokens=800000`.
7
+ // Only the parser's own budget names are accepted, as positive integers; anything else is rejected and reported, never guessed.
8
+ // 2. A persisted record. Each scan writes `.agentic-security/haskell-budget.json`: the effective budgets, the count of completed
9
+ // files, and every remaining file with the reason and a digest of its content.
10
+ // 3. A comparison. The next scan reads that record and states, for each previously remaining file, whether it is now complete,
11
+ // still remaining on the same inputs and budgets (a deterministic bound will be hit again: raise it), still remaining after
12
+ // a change, or gone.
13
+ //
14
+ // What it does not do: resume. The Haskell pass parses every eligible file on every scan, so nothing is reused from a previous
15
+ // run, and nothing is skipped because it was analysed before. The record proves what was and was not finished; it does not
16
+ // shortcut the work. `deadlineMs` is wall-clock time, so a file can complete on one machine and exceed it on another.
17
+
18
+ import * as fs from 'node:fs';
19
+ import * as crypto from 'node:crypto';
20
+ import { statePath, safeWriteState } from '../posture/state-dir.js';
21
+ import { DEFAULT_PARSE_BUDGETS } from './haskell-parser.js';
22
+
23
+ export const BUDGET_STATE_SCHEMA = 'haskell-budget-state/1';
24
+ export const HASKELL_PARSE_BUDGET_ENV = 'AGENTIC_SECURITY_HASKELL_PARSE_BUDGETS';
25
+ const STATE_FILE = 'haskell-budget.json';
26
+ const MAX_RECORDED = 5000;
27
+
28
+ /** Parse `name=value,...` into parser budget overrides. Returns { budgets, rejected }. */
29
+ export function parseBudgetOverrides(raw) {
30
+ const budgets = {};
31
+ const rejected = [];
32
+ for (const part of String(raw || '').split(',').map((s) => s.trim()).filter(Boolean)) {
33
+ const m = /^([A-Za-z]+)\s*=\s*(\d+)$/.exec(part);
34
+ if (!m) { rejected.push({ entry: part, reason: 'expected name=positive-integer' }); continue; }
35
+ if (!Object.prototype.hasOwnProperty.call(DEFAULT_PARSE_BUDGETS, m[1])) { rejected.push({ entry: part, reason: `unknown budget "${m[1]}"` }); continue; }
36
+ const v = Number(m[2]);
37
+ if (!Number.isSafeInteger(v) || v <= 0) { rejected.push({ entry: part, reason: 'value must be a positive integer' }); continue; }
38
+ budgets[m[1]] = v;
39
+ }
40
+ return { budgets, rejected };
41
+ }
42
+
43
+ export const contentDigest = (text) => crypto.createHash('sha256').update(String(text ?? '')).digest('hex');
44
+
45
+ export function loadBudgetState(scanRoot) {
46
+ if (!scanRoot) return null;
47
+ try {
48
+ const doc = JSON.parse(fs.readFileSync(statePath(scanRoot, STATE_FILE), 'utf8'));
49
+ if (!doc || doc.schema !== BUDGET_STATE_SCHEMA || !Array.isArray(doc.remaining)) return null;
50
+ return doc;
51
+ } catch { return null; }
52
+ }
53
+
54
+ const sameBudgets = (a, b) => JSON.stringify(Object.entries(a || {}).sort()) === JSON.stringify(Object.entries(b || {}).sort());
55
+
56
+ /**
57
+ * Build this scan's record and compare it with the previous one.
58
+ * @param {{remaining:{file:string,parse:string,flowAnalysis:string,budgetName?:string}[], completedFiles:number,
59
+ * budgets:object, files:Record<string,string>, previous:object|null}} i
60
+ */
61
+ export function budgetRecord({ remaining, completedFiles, budgets, files, previous }) {
62
+ const rows = remaining.slice(0, MAX_RECORDED).map((r) => ({ ...r, digest: contentDigest(files[r.file]) }));
63
+ const record = { schema: BUDGET_STATE_SCHEMA, budgets, completedFiles, remainingFiles: remaining.length, remaining: rows, truncated: remaining.length > rows.length };
64
+ if (!previous) return { record, sinceLast: null };
65
+ const now = new Map(rows.map((r) => [r.file, r]));
66
+ const budgetsChanged = !sameBudgets(previous.budgets, budgets);
67
+ const sinceLast = { budgetsChanged, completed: [], stillRemainingSameInputs: [], stillRemainingChanged: [], gone: [], newlyRemaining: [] };
68
+ for (const p of previous.remaining) {
69
+ if (!(p.file in files)) { sinceLast.gone.push(p.file); continue; }
70
+ const cur = now.get(p.file);
71
+ if (!cur) { sinceLast.completed.push(p.file); continue; }
72
+ // Both digests are hashes of source files the scan already read, not secrets: comparison time reveals nothing.
73
+ if (cur.digest === p.digest && !budgetsChanged) sinceLast.stillRemainingSameInputs.push(p.file); // agentic-security-ignore: CWE-208
74
+ else sinceLast.stillRemainingChanged.push(p.file);
75
+ }
76
+ const before = new Set(previous.remaining.map((p) => p.file));
77
+ for (const r of rows) if (!before.has(r.file)) sinceLast.newlyRemaining.push(r.file);
78
+ for (const k of Object.keys(sinceLast)) if (Array.isArray(sinceLast[k])) sinceLast[k].sort();
79
+ return { record, sinceLast };
80
+ }
81
+
82
+ export function saveBudgetState(scanRoot, record) {
83
+ if (!scanRoot) return false;
84
+ return safeWriteState(statePath(scanRoot, STATE_FILE), JSON.stringify(record, null, 1) + '\n');
85
+ }
@@ -0,0 +1,230 @@
1
+ // Haskell coverage accounting (PRD FR 02).
2
+ //
3
+ // Answers "what actually ran" for Haskell, counted in separate stages so a
4
+ // recognised extension, a successful parse and a completed data-flow analysis
5
+ // are never conflated:
6
+ //
7
+ // read -> parse -> ruleEvaluation -> graphInclusion -> flowAnalysis
8
+ //
9
+ // Every eligible file is in the denominator whether it parsed or not. A file
10
+ // excluded by policy, a size cap or an excluded directory is listed with its
11
+ // reason and is not eligible. Nothing here reads a file, runs a tool or
12
+ // executes project code: it summarises results the engine already produced.
13
+ //
14
+ // Pure: inputs in, one record out.
15
+
16
+ import * as path from 'node:path';
17
+ import { parseCabalFile, parseHpackFile } from './haskell-manifests.js';
18
+
19
+ export const COVERAGE_SCHEMA = 'haskell-coverage/1';
20
+ export const DEFAULT_THRESHOLDS = Object.freeze({ parseRate: 0.95, semanticRate: 0.90 });
21
+
22
+ const HS_FORM = /\.(lhs-boot|hs-boot|lhs|hs|hsig|hsc)$/i;
23
+ const norm = (p) => String(p || '').replace(/\\/g, '/');
24
+ export const formOf = (f) => { const m = HS_FORM.exec(norm(f)); return m ? m[1].toLowerCase() : null; };
25
+ const isPlainHs = (f) => formOf(f) === 'hs';
26
+
27
+ /** Rule families the Haskell pipeline can run. `needsDeep` ones only run under the IR layer. */
28
+ export const RULE_FAMILIES = Object.freeze([
29
+ { id: 'hs-security-rules', label: 'crypto, randomness, resource, XML and sensitive-logging rules', needsDeep: false },
30
+ { id: 'hs-web', label: 'web routes and authorization', needsDeep: false },
31
+ { id: 'hs-llm', label: 'LLM usage rules', needsDeep: false },
32
+ { id: 'ir-taint', label: 'interprocedural taint (SQL, command, path, SSRF, ...)', needsDeep: true },
33
+ { id: 'hs-sca', label: 'dependency manifests and advisories', needsDeep: false },
34
+ ]);
35
+
36
+ function packageIndex(files) {
37
+ const roots = [];
38
+ for (const [p, text] of Object.entries(files)) {
39
+ const np = norm(p);
40
+ const base = np.split('/').pop();
41
+ let r = null;
42
+ try {
43
+ if (/\.cabal$/i.test(base)) r = parseCabalFile(text, { file: np });
44
+ else if (base === 'package.yaml') r = parseHpackFile(text, { file: np });
45
+ } catch { r = null; }
46
+ if (r) roots.push({ dir: path.posix.dirname(np) === '.' ? '' : path.posix.dirname(np), name: (r.package && r.package.name) || path.posix.basename(path.posix.dirname(np)) || '(root)', manifest: np });
47
+ }
48
+ return roots.sort((a, b) => b.dir.length - a.dir.length);
49
+ }
50
+
51
+ function packageOf(file, index, sourceMap) {
52
+ const f = norm(file);
53
+ if (sourceMap && sourceMap.packages) {
54
+ let best = null;
55
+ for (const [name, prefixes] of Object.entries(sourceMap.packages)) {
56
+ for (const pre of [].concat(prefixes)) {
57
+ const p = norm(pre).replace(/\/+$/, '');
58
+ if ((f === p || f.startsWith(p + '/')) && (!best || p.length > best.len)) best = { name, len: p.length };
59
+ }
60
+ }
61
+ if (best) return { name: best.name, source: 'source-map' };
62
+ }
63
+ for (const r of index) if (r.dir === '' || f.startsWith(r.dir + '/')) return { name: r.name, source: 'manifest' };
64
+ return { name: '(unassigned)', source: 'none' };
65
+ }
66
+
67
+ const rate = (n, d) => (d > 0 ? n / d : null);
68
+
69
+ /**
70
+ * @param {object} i
71
+ * @param {Record<string,string>} i.files sources and manifests the scan read
72
+ * @param {Map<string,object>|Record<string,object>} [i.parses] file -> parseHaskell result
73
+ * @param {object|null} [i.ir] buildHaskellIR result, or null when no graph was built
74
+ * @param {boolean} i.deepEnabled
75
+ * @param {Record<string,{ran:boolean, failed?:string, findings?:number}>} [i.ruleRuns]
76
+ * @param {{file:string,bytes:number,cap:number}[]} [i.sizeSkipped]
77
+ * @param {string[]} [i.policyExcluded]
78
+ * @param {string[]} [i.excludedPaths] language-excluded build-output paths seen
79
+ * @param {{packages:Record<string,string|string[]>}|null} [i.sourceMap]
80
+ * @param {{parseRate:number, semanticRate:number}} [i.thresholds]
81
+ */
82
+ export function buildHaskellCoverage(i) {
83
+ const files = i.files || {};
84
+ const th = { ...DEFAULT_THRESHOLDS, ...(i.thresholds || {}) };
85
+ const parses = i.parses instanceof Map ? i.parses : new Map(Object.entries(i.parses || {}));
86
+ const ir = i.ir || null;
87
+ const index = packageIndex(files);
88
+ const byFile = {};
89
+ const skipped = [];
90
+
91
+ const sizeSkipped = new Map((i.sizeSkipped || []).filter((x) => formOf(x.file)).map((x) => [norm(x.file), x]));
92
+ const policy = new Set((i.policyExcluded || []).map(norm).filter((f) => formOf(f)));
93
+ const excl = new Set((i.excludedPaths || []).map(norm).filter((f) => formOf(f)));
94
+ const present = Object.keys(files).map(norm).filter((f) => formOf(f));
95
+
96
+ const add = (file, status) => { byFile[file] = { form: formOf(file), package: packageOf(file, index, i.sourceMap).name, ...status }; };
97
+
98
+ for (const f of [...sizeSkipped.keys()]) {
99
+ const s = sizeSkipped.get(f);
100
+ add(f, { eligible: true, read: 'oversize', parse: 'not-attempted', ruleEvaluation: 'not-run', graphInclusion: 'not-in-graph', flowAnalysis: 'not-run', reason: `over the read cap (${s.bytes} > ${s.cap} bytes)` });
101
+ }
102
+ for (const f of policy) if (!byFile[f]) { add(f, { eligible: false, read: 'not-read', reason: 'default-ignored directory' }); skipped.push({ file: f, reason: 'default-ignored directory' }); }
103
+ for (const f of excl) if (!byFile[f]) { add(f, { eligible: false, read: 'not-read', reason: 'build-output directory' }); skipped.push({ file: f, reason: 'build-output directory' }); }
104
+
105
+ const familyRan = (id) => !!(i.ruleRuns && i.ruleRuns[id] && i.ruleRuns[id].ran);
106
+ const staticRulesRan = familyRan('hs-security-rules') && familyRan('hs-web');
107
+
108
+ for (const f of present.sort()) {
109
+ if (byFile[f]) continue;
110
+ const form = formOf(f);
111
+ const p = parses.get(f);
112
+ const parse = !p ? 'not-attempted' : p.status; // parsed | parsed_with_errors | failed | budget_exceeded | missing_grammar
113
+ const parsedOk = parse === 'parsed' || parse === 'parsed_with_errors';
114
+ // The rule modules and the IR read `.hs` only; every other form is parse-only and is said to be.
115
+ const plain = form === 'hs';
116
+ const ruleEvaluation = !plain ? 'unsupported-form' : !parsedOk ? 'not-run' : (staticRulesRan ? 'evaluated' : 'not-run');
117
+ const irFile = ir && ir.perFile && ir.perFile[f];
118
+ const graphInclusion = !plain ? 'parse-only' : (irFile ? 'included' : 'not-in-graph');
119
+ let flowAnalysis;
120
+ let fnDiscovered = p && Array.isArray(p.functions) ? p.functions.length : 0;
121
+ let fnLowered = 0; let fnIncomplete = 0;
122
+ if (irFile) {
123
+ const fns = irFile.functions.filter((x) => !(x.hs && x.hs.kind === 'module-init'));
124
+ fnLowered = fns.length; fnIncomplete = fns.filter((x) => x.hs && x.hs.incomplete).length;
125
+ fnDiscovered = Math.max(fnDiscovered, fnLowered);
126
+ }
127
+ const capped = ir && (ir.diagnostics || []).some((d) => d.file === f && (d.kind === 'function-cap' || d.kind === 'ir-lowering-failure' || d.kind === 'ir-parse-failure'));
128
+ if (!plain) flowAnalysis = 'unsupported-form';
129
+ else if (!i.deepEnabled) flowAnalysis = 'not-run:deep-analysis-disabled';
130
+ else if (graphInclusion !== 'included') flowAnalysis = 'not-run:not-in-graph';
131
+ else if (capped || fnIncomplete > 0 || parse === 'parsed_with_errors') flowAnalysis = 'partial';
132
+ else flowAnalysis = 'completed';
133
+ const boundaries = p && Array.isArray(p.boundaries) ? [...new Set(p.boundaries.map((b) => b.kind))].sort() : [];
134
+ add(f, {
135
+ eligible: true, read: 'read', parse, ruleEvaluation, graphInclusion, flowAnalysis,
136
+ functions: { discovered: fnDiscovered, lowered: fnLowered, incomplete: fnIncomplete },
137
+ ...(boundaries.length ? { boundaries } : {}),
138
+ ...(p && p.errors && p.errors.length ? { parseErrors: p.errors.length } : {}),
139
+ });
140
+ }
141
+
142
+ const rows = Object.entries(byFile);
143
+ const eligible = rows.filter(([, r]) => r.eligible);
144
+ const eligibleHs = eligible.filter(([f]) => isPlainHs(f));
145
+
146
+ const byForm = {};
147
+ for (const [f, r] of rows) { const k = r.form; (byForm[k] = byForm[k] || { discovered: 0, eligible: 0, parsed: 0, parsedWithErrors: 0, failed: 0 }); byForm[k].discovered++; if (r.eligible) byForm[k].eligible++; if (r.parse === 'parsed') byForm[k].parsed++; else if (r.parse === 'parsed_with_errors') byForm[k].parsedWithErrors++; else if (r.eligible) byForm[k].failed++; }
148
+
149
+ const byPackage = {};
150
+ for (const [, r] of rows) {
151
+ const e = (byPackage[r.package] = byPackage[r.package] || { discovered: 0, eligible: 0, parsed: 0, semanticComplete: 0 });
152
+ e.discovered++; if (r.eligible) e.eligible++; if (r.parse === 'parsed') e.parsed++; if (r.flowAnalysis === 'completed') e.semanticComplete++;
153
+ }
154
+
155
+ const parsedHs = eligibleHs.filter(([, r]) => r.parse === 'parsed').length;
156
+ const semanticHs = eligibleHs.filter(([, r]) => r.flowAnalysis === 'completed').length;
157
+ const fn = { discovered: 0, lowered: 0, incomplete: 0 };
158
+ for (const [, r] of eligible) if (r.functions) { fn.discovered += r.functions.discovered; fn.lowered += r.functions.lowered; fn.incomplete += r.functions.incomplete; }
159
+ const unresolvedCalls = ir && ir.callGraph && Array.isArray(ir.callGraph.unresolved) ? ir.callGraph.unresolved.length : (ir ? 0 : null);
160
+
161
+ const families = RULE_FAMILIES.map((fam) => {
162
+ const run = (i.ruleRuns && i.ruleRuns[fam.id]) || null;
163
+ const ranDeep = fam.needsDeep ? (i.deepEnabled && !!ir) : true;
164
+ const ran = !!(run && run.ran) && ranDeep;
165
+ // A caller that did not report rule runs (a direct assurance call, not an engine scan) gets 'not-reported': unknown, not a failure.
166
+ if (!i.ruleRuns) return { id: fam.id, label: fam.label, ran: null, status: 'not-reported', findings: null };
167
+ return { id: fam.id, label: fam.label, ran, status: ran ? 'executed' : (fam.needsDeep && !i.deepEnabled ? 'not-run:deep-analysis-disabled' : (run && run.failed ? `failed:${run.failed}` : 'not-run')), findings: run && Number.isInteger(run.findings) ? run.findings : null };
168
+ });
169
+
170
+ const reasons = [];
171
+ const limitations = [];
172
+ if (present.length + sizeSkipped.size > 0) {
173
+ if (!i.deepEnabled) reasons.push('deep analysis is disabled: no Haskell data-flow analysis ran');
174
+ for (const f of families) if (f.ran === false && !(f.id === 'ir-taint' && !i.deepEnabled) && f.id !== 'hs-sca') reasons.push(`rule family ${f.id} did not run (${f.status})`);
175
+ const pr = rate(parsedHs, eligibleHs.length);
176
+ const sr = rate(semanticHs, eligibleHs.length);
177
+ if (pr !== null && pr < th.parseRate) reasons.push(`parse success ${(pr * 100).toFixed(1)}% is below ${(th.parseRate * 100)}% of eligible .hs files`);
178
+ if (sr !== null && sr < th.semanticRate) reasons.push(`semantic analysis completed for ${(sr * 100).toFixed(1)}% of eligible .hs files, below ${(th.semanticRate * 100)}%`);
179
+ const nonHs = eligible.filter(([f]) => !isPlainHs(f)).length;
180
+ if (nonHs) limitations.push(`${nonHs} eligible file(s) in a non-.hs form (.lhs, .hs-boot, .hsig, .hsc) are parse-only: no rules or data-flow analysis`);
181
+ }
182
+
183
+ const incompleteEntries = eligibleHs.filter(([, r]) => r.parse !== 'parsed' || r.flowAnalysis !== 'completed')
184
+ .map(([file, r]) => ({ file, parse: r.parse, flowAnalysis: r.flowAnalysis })).slice(0, 500);
185
+ const mains = [];
186
+ if (ir && ir.callGraph && ir.callGraph.functions) {
187
+ for (const f of ir.callGraph.functions.values()) if (/\.main$/.test(f.name || '') && byFile[f.file] && byFile[f.file].flowAnalysis !== 'completed') mains.push({ name: f.name, file: f.file, flowAnalysis: byFile[f.file].flowAnalysis });
188
+ }
189
+
190
+ // Work completed and work remaining when a limit stopped the analysis (PRD FR 02). A file is remaining when its parse hit a
191
+ // budget or its flow analysis did not complete. Nothing is sampled: the lists are exhaustive up to the cap, in path order, so a
192
+ // rerun over the same inputs names the same files. `resume` says only what the scanner can do today (assurance.js adds the
193
+ // effective budgets and the comparison with the previous scan's record, language/haskell-budget-state.js).
194
+ const remaining = eligibleHs.filter(([, r]) => r.parse === 'budget_exceeded' || (r.parse === 'parsed' && r.flowAnalysis !== 'completed' && r.flowAnalysis !== 'not-applicable'))
195
+ .map(([file, r]) => ({ file, parse: r.parse, flowAnalysis: r.flowAnalysis })).sort((a, b) => (a.file < b.file ? -1 : 1));
196
+ const budget = {
197
+ exhausted: eligibleHs.some(([, r]) => r.parse === 'budget_exceeded'),
198
+ completedFiles: eligibleHs.length - remaining.length,
199
+ remainingFiles: remaining.length,
200
+ remaining: remaining.slice(0, 500),
201
+ remainingTruncated: remaining.length > 500,
202
+ // The Haskell pass parses every eligible file on every scan: nothing is reused from a previous run, so there is no resume.
203
+ resume: { supported: false, toFinish: 'raise the named parse budget with AGENTIC_SECURITY_HASKELL_PARSE_BUDGETS (name=value,...) and rescan; a file whose flow analysis did not run needs deep analysis on. The remaining files are never skipped silently, and the capability stays incomplete until they are analysed' },
204
+ };
205
+ // The full list (not capped) for the persisted record in haskell-budget-state.js; non-enumerable so the report shape is unchanged.
206
+ Object.defineProperty(budget, 'remainingAll', { value: remaining, enumerable: false });
207
+ const hasHaskell = present.length + sizeSkipped.size + policy.size + excl.size > 0;
208
+ return {
209
+ schema: COVERAGE_SCHEMA,
210
+ present: hasHaskell,
211
+ // `complete` only when every stage that was required ran to completion; never inferred from a zero-finding scan.
212
+ capability: { status: !hasHaskell ? 'not-applicable' : (reasons.length || sizeSkipped.size) ? 'incomplete' : 'complete', reasons, limitations },
213
+ totals: { discovered: rows.length, eligible: eligible.length, eligibleHs: eligibleHs.length, skipped: skipped.length },
214
+ byForm, byPackage,
215
+ stages: {
216
+ parse: { success: parsedHs, partial: eligibleHs.filter(([, r]) => r.parse === 'parsed_with_errors').length, failed: eligibleHs.filter(([, r]) => !['parsed', 'parsed_with_errors'].includes(r.parse)).length, rate: rate(parsedHs, eligibleHs.length) },
217
+ semantic: { completed: semanticHs, rate: rate(semanticHs, eligibleHs.length) },
218
+ },
219
+ functions: fn,
220
+ unresolvedCalls,
221
+ ruleFamilies: families,
222
+ thresholds: th,
223
+ meetsThresholds: hasHaskell ? ((rate(parsedHs, eligibleHs.length) ?? 1) >= th.parseRate && (rate(semanticHs, eligibleHs.length) ?? 1) >= th.semanticRate) : null,
224
+ incompleteEntries,
225
+ incompleteMains: mains,
226
+ budget,
227
+ skipped: skipped.slice(0, 500),
228
+ byFile,
229
+ };
230
+ }
@@ -17,6 +17,7 @@
17
17
  // has the prefix. A weak guard is reported as a note on the finding and does not refute it.
18
18
 
19
19
  const CANON_CALLS = new Set(['System.Directory.canonicalizePath', 'System.Directory.makeAbsolute', 'System.FilePath.normalise']);
20
+ import { createHash } from 'node:crypto';
20
21
  import { HS_SANITIZERS } from './haskell-models.js';
21
22
  // the catalog's HTML escapers (blaze, lucid): the same list the taint catalog is generated from
22
23
  const CATALOG_HTML_ESCAPERS = new Set(HS_SANITIZERS.filter((s) => (s.appliesTo || []).includes('xss')).map((s) => `${s.module}.${s.name}`));
@@ -570,6 +571,11 @@ const REQUEST_BUILDERS = /(?:^|\.)(?:parseRequest|parseRequest_|parseUrlThrow|pa
570
571
  const REQUEST_MODIFIERS = /(?:^|\.)(?:setRequestQueryString|setRequestQuery|setQueryString|setRequestPath|setRequestBody|setRequestBodyLBS|setRequestBodyJSON|setRequestBodyURLEncoded|setRequestBodyFile|setRequestBodySource|setRequestHeader|setRequestHeaders|addRequestHeader|setRequestMethod|setRequestBasicAuth|setRequestBearerAuth|setRequestIgnoreStatus|setRequestResponseTimeout|setRequestCheckStatus|setRequestManager|urlEncodedBody|applyBasicAuth|applyBearerAuth)$/;
571
572
  const PEEL_WRAPPERS = /(?:^|\.)(?:liftIO|pure|return|unpack|pack|fromString|toStrict|fromStrict|encodeUtf8|decodeUtf8|toText|fromText)$/;
572
573
  const HOST_FIELD = /^(?:host|port|secure|url|uri|requestHost)$/i;
574
+ // Record fields and modifiers that leave the destination exactly as the literal names it. Anything else on the way (a concatenation, a
575
+ // path setter, a custom manager, which can carry a proxy, an unknown field) leaves the PATH or the route open, and then the finding is
576
+ // only lowered, never refuted.
577
+ const DESTINATION_NEUTRAL_FIELD = /^(?:method|requestHeaders|requestBody|queryString|responseTimeout|checkResponse|redirectCount|decompress|requestVersion|cookieJar)$/;
578
+ const PATH_OPENING_MODIFIER = /(?:^|\.)(?:setRequestPath|setRequestManager)$/;
573
579
 
574
580
  function fixedHostRequest(f, perFile) {
575
581
  if (!f || f.cwe !== 'CWE-918') return null;
@@ -593,6 +599,7 @@ function fixedHostRequest(f, perFile) {
593
599
  return rets.length === 1 ? rets[0].value : null;
594
600
  };
595
601
  let evidence = null;
602
+ let open = false;
596
603
  const fixed = (e, seen, depth) => {
597
604
  if (!e || typeof e !== 'object' || depth > 24) return false;
598
605
  if (e.kind === 'literal') { const ok = typeof e.value === 'string' && HOST_ANCHORED.test(e.value); if (ok) evidence = e.value.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/).*$/i, '$1'); return ok; }
@@ -606,15 +613,16 @@ function fixedHostRequest(f, perFile) {
606
613
  if (e.kind === 'object' && Array.isArray(e.props)) {
607
614
  // a record update: `{ method = "POST" }` joined (as a union) with its base; a field that names the host makes it unknown
608
615
  if (e.props.some((p) => HOST_FIELD.test(String(p.key || p.name || '')))) return false;
616
+ if (e.props.some((p) => !DESTINATION_NEUTRAL_FIELD.test(String(p.key || p.name || '')))) open = true;
609
617
  return e.object ? fixed(e.object, seen, depth + 1) : true;
610
618
  }
611
- if (e.kind === 'binary' && (e.op === '<>' || e.op === '++' || (e.hs && e.hs.concat))) return fixed(e.left, seen, depth + 1);
619
+ if (e.kind === 'binary' && (e.op === '<>' || e.op === '++' || (e.hs && e.hs.concat))) { open = true; return fixed(e.left, seen, depth + 1); }
612
620
  if (e.kind === 'call' && typeof e.callee === 'string') {
613
621
  const args = e.args || [];
614
622
  if (PEEL_WRAPPERS.test(e.callee) && args.length === 1) return fixed(args[0], seen, depth + 1);
615
623
  if (REQUEST_BUILDERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
616
- if (REQUEST_MODIFIERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
617
- if (/(?:^|\.)(?:<\/>|combine|joinPath)$/.test(e.callee) && args.length === 2) return fixed(args[0], seen, depth + 1);
624
+ if (REQUEST_MODIFIERS.test(e.callee) && args.length >= 1) { if (PATH_OPENING_MODIFIER.test(e.callee)) open = true; return fixed(args[args.length - 1], seen, depth + 1); }
625
+ if (/(?:^|\.)(?:<\/>|combine|joinPath)$/.test(e.callee) && args.length === 2) { open = true; return fixed(args[0], seen, depth + 1); }
618
626
  }
619
627
  return false;
620
628
  };
@@ -625,10 +633,10 @@ function fixedHostRequest(f, perFile) {
625
633
  // update alone, which is fixed when it names no host field, so the base branch decides
626
634
  if (!fixed(arg, new Set(), 0)) return null;
627
635
  }
628
- return { kind: 'fixed-host-request', base: evidence };
636
+ return { kind: 'fixed-host-request', base: evidence, destinationFixed: !open };
629
637
  }
630
638
 
631
- export function applyHaskellGuards(findings, perFile) {
639
+ export function applyHaskellGuards(findings, perFile, fileContents = null) {
632
640
  const stats = { refuted: 0, weak: 0 };
633
641
  for (const f of findings || []) {
634
642
  if (!f || f.parser !== 'IR-TAINT') continue;
@@ -656,6 +664,19 @@ export function applyHaskellGuards(findings, perFile) {
656
664
  f._fixedHost = true; f.severity = 'info';
657
665
  f.controls = [...(f.controls || []), { kind: 'fixed-host-request', evidenceKind: 'source' }];
658
666
  f.guardNote = { kind: 'fixed-host-request', reason: `the request's host is fixed by the literal${fh.base ? ` "${fh.base}"` : ''} the URL or base request starts from; the client's text can only choose the path, query or fragment, which is not a server-side request forgery (a path it chooses on that host is still worth a look)` };
667
+ // When the whole destination (scheme, host and path) is the literal and the client's text reaches only the query, headers, body,
668
+ // method or authentication, the claim that the client chooses where the request goes is disproved by the code itself: an
669
+ // evidence-backed refutation, tied to this file's content. A path the client can still choose is lowered only (above).
670
+ const text = fileContents && typeof fileContents[f.file] === 'string' ? fileContents[f.file] : null;
671
+ if (fh.destinationFixed && text !== null) {
672
+ f.refutation = {
673
+ kind: 'constant-input', verifier: 'haskell-guards/fixed-host-request', version: 1,
674
+ // the host goes in the evidence, not the reason: a host name such as `internal.example.com` is not a context excuse
675
+ evidenceRefs: [`${f.file}:${f.line}`, ...(fh.base ? [`destination:${fh.base}`] : [])],
676
+ reason: 'the request\'s destination is a literal in full (scheme, host and path); the client\'s text reaches only the query, headers, body, method or authentication',
677
+ sourceHash: createHash('sha256').update(text).digest('hex'),
678
+ };
679
+ }
659
680
  stats.weak++;
660
681
  continue;
661
682
  }