@clear-capabilities/agentic-security-scanner 0.195.15 → 0.195.17

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.
@@ -65,7 +65,9 @@ const litValue = (t) => t.slice(1, -1);
65
65
  /** `"a" ++ n ++ "b"` -> [{lit:'a'},{var:'n'},{lit:'b'}], or null for anything we cannot prove simple. */
66
66
  function concatParts(expr) {
67
67
  const e = trimParens(expr);
68
- const raw = topLevelSplit(e, '++');
68
+ let raw = topLevelSplit(e, '++');
69
+ if (raw && raw.length === 1) { const t = topLevelSplit(e, '<>'); if (t && t.length > 1) raw = t; } // a Text (Semigroup) concatenation
70
+ else if (raw && topLevelSplit(e, '<>')?.length > 1) return null; // both operators: not a shape this reads
69
71
  if (!raw) return null;
70
72
  const out = [];
71
73
  for (const r of raw) {
@@ -188,15 +190,41 @@ function applyImportDelta(orig, withImports, body) {
188
190
 
189
191
  function fixProcess(text, f) {
190
192
  const lb = lineBounds(text, f.line);
191
- const call = findCall(lb.text, ['callCommand', 'system']);
193
+ const call = findCall(lb.text, ['callCommand', 'system', 'spawnCommand']);
192
194
  if (!call) return null;
193
195
  let aStart = call.after; while (lb.text[aStart] === ' ') aStart++;
194
196
  let aEnd;
195
197
  if (lb.text[aStart] === '$') { aStart++; while (lb.text[aStart] === ' ') aStart++; aEnd = lb.text.length; }
196
198
  else aEnd = atomEnd(lb.text, aStart);
197
199
  if (aEnd < 0) return null;
198
- const parts = concatParts(lb.text.slice(aStart, aEnd));
200
+ // Since 0.195.16: a Text command, `system (cs t)` / `(T.unpack t)`, whose Text is a `<>` concatenation on the line or in a one-line
201
+ // `let` of the same function used nowhere else. The unpack is kept per piece, so each argument is still a String.
202
+ let argText = trimParens(lb.text.slice(aStart, aEnd));
203
+ let unpackFn = null; let letLine = -1;
204
+ const um = /^(cs|(?:[A-Z][\w']*\.)*unpack)\s+(.+)$/.exec(argText);
205
+ if (um) { unpackFn = um[1]; argText = trimParens(um[2]); }
206
+ if (IDENT.test(argText)) {
207
+ const ls = text.split('\n');
208
+ let start = 0; for (let i = f.line - 1; i >= 0; i--) { const mm = TOP_DECL.exec(ls[i] || ''); if (mm && !NOT_A_DEF.has(mm[1])) { start = i; break; } }
209
+ let end = ls.length; for (let i = f.line; i < ls.length; i++) { const mm = TOP_DECL.exec(ls[i] || ''); if (mm && !NOT_A_DEF.has(mm[1])) { end = i; break; } }
210
+ const uses = new RegExp(`(?<![\\w'.])${argText}(?![\\w'])`, 'g');
211
+ const body = ls.slice(start, end);
212
+ let bound = null;
213
+ for (let i = 0; i < body.length; i++) { const mm = new RegExp(`^\\s+let\\s+${argText}\\s*=\\s*(.+)$`).exec(body[i]); if (mm && start + i < f.line - 1) { bound = { i: start + i, expr: mm[1].trim() }; } }
214
+ const count = (body.join('\n').match(uses) || []).length;
215
+ if (bound && count === 2 && !/^\s+let\b/.test(ls[bound.i + 1] || '') && !/^\s{2,}\S/.test(ls[bound.i + 1] || '') || (bound && count === 2 && !(ls[bound.i + 1] || '').trim())) { argText = trimParens(bound.expr); letLine = bound.i; }
216
+ else if (bound && count === 2) {
217
+ // the binding has no continuation only when the next line is not indented deeper than `let`'s binding
218
+ const letIndent = (/^\s*/.exec(ls[bound.i]) || [''])[0].length;
219
+ const nextIndent = (/^\s*/.exec(ls[bound.i + 1] || '') || [''])[0].length;
220
+ if ((ls[bound.i + 1] || '').trim() && nextIndent > letIndent) return null;
221
+ argText = trimParens(bound.expr); letLine = bound.i;
222
+ }
223
+ }
224
+ if (/(?<![\w'])(?:if|case|let|do)(?![\w'])/.test(argText)) return null;
225
+ const parts = concatParts(argText);
199
226
  if (!parts || !parts.some((p) => p.var)) return null; // a bare variable: no provable argv split
227
+ if (unpackFn === null && parts.some((p) => p.var) && /\bcs\b|unpack/.test(argText)) return null;
200
228
  // tokenise into words; a word is a list of pieces
201
229
  const words = []; let cur = [];
202
230
  const flush = () => { if (cur.length) { words.push(cur); cur = []; } };
@@ -209,7 +237,8 @@ function fixProcess(text, f) {
209
237
  if (!words.length || words[0].some((p) => p.var)) return null; // the program itself must be a literal
210
238
  if (/[|&;<>`$*?()\\]/.test(words[0].map((p) => p.lit).join('')) || words.slice(1).some((w) => w.every((p) => p.lit !== undefined) && /[|&;<>`$*?()]/.test(w.map((p) => p.lit).join('')))) return null; // shell syntax in the literal: not an argv
211
239
  const prog = words[0].map((p) => p.lit).join('');
212
- const argExpr = (w) => (w.length === 1 && w[0].lit !== undefined ? `"${w[0].lit}"` : (w.length === 1 ? w[0].var : `(${w.map((p) => (p.lit !== undefined ? `"${p.lit}"` : p.var)).join(' ++ ')})`));
240
+ const v = (name) => (unpackFn ? `${unpackFn} ${name}` : name);
241
+ const argExpr = (w) => (w.length === 1 && w[0].lit !== undefined ? `"${w[0].lit}"` : (w.length === 1 ? v(w[0].var) : `(${w.map((p) => (p.lit !== undefined ? `"${p.lit}"` : v(p.var))).join(' ++ ')})`));
213
242
  let args = words.slice(1);
214
243
  // End option parsing before the first value-bearing argument, but only when no earlier literal is itself an
215
244
  // option (it might take a value, and "--" would then be consumed as that value). Otherwise leave it to the gate.
@@ -222,7 +251,9 @@ function fixProcess(text, f) {
222
251
  const imp = ensureImport(text, 'System.Process', [fn]); if (!imp) return null;
223
252
  const repl = `${call.prefix || imp.prefix}${fn} "${prog}" [${args.map(argExpr).join(', ')}]`;
224
253
  const replaced = lb.text.slice(0, call.index) + repl + lb.text.slice(aEnd);
225
- const next = applyImportDelta(text, imp.text, splice(text, lb.start, lb.end, replaced));
254
+ let edited = splice(text, lb.start, lb.end, replaced);
255
+ if (letLine >= 0) { const ls = edited.split('\n'); ls.splice(letLine, 1); edited = ls.join('\n'); } // the one-line `let` is now unused: -Werror would reject it
256
+ const next = applyImportDelta(text, imp.text, edited);
226
257
  return { ruleId: 'hs-process-argv', label: 'MITIGATION', text: next, explanation: `Replaced a shell command string with a direct ${fn} of "${prog}" and an argument list; no shell parses the value${whole ? '' : ' (a value fused into an argument is still passed as data)'}. ${terminated ? 'A "--" now ends option parsing before the value (assumes the program follows that convention).' : 'A value that starts with "-" can still be read as an option by the program: add "--" or validate it.'}`, behavior: { kind: 'process', program: prog, argv: args.map((w) => w.map((p) => (p.lit !== undefined ? { lit: p.lit } : { var: p.var }))) } };
227
258
  }
228
259
 
@@ -279,6 +310,14 @@ function splitOperators(expr) {
279
310
 
280
311
  /** The declared types of the parameters of the top-level function containing `line`: { name -> type text }, from its signature and first clause. */
281
312
  function paramTypesAt(text, line) {
313
+ const sig = signatureAt(text, line);
314
+ if (!sig) return null;
315
+ const out = {};
316
+ sig.params.forEach((p, i) => { if (sig.types[i] !== undefined) out[p] = sig.types[i]; });
317
+ return out;
318
+ }
319
+ /** The top-level clause enclosing `line` (1-based) and its signature: { name, params, types (the last is the result), clause (0-based line) }. */
320
+ function signatureAt(text, line) {
282
321
  const lines = text.split('\n');
283
322
  let clause = -1;
284
323
  for (let i = Math.min(line - 1, lines.length - 1); i >= 0; i--) if (/^[a-z_][\w']*\b[^:\n]*=/.test(lines[i]) && !/^[a-z_][\w']*\s*::/.test(lines[i])) { clause = i; break; }
@@ -300,9 +339,45 @@ function paramTypesAt(text, line) {
300
339
  cur += c;
301
340
  }
302
341
  types.push(cur.trim());
303
- const out = {};
304
- params.forEach((p, i) => { if (types[i] !== undefined) out[p] = types[i]; });
305
- return out;
342
+ return { name, params, types, clause };
343
+ }
344
+
345
+ // hs-path-contain (0.195.16): a path parameter that reaches a file operation anywhere in its function (the sink may sit in a callee or a
346
+ // `where` helper: the two dhall test-server sites are a read and an existence test under one FilePath parameter). A guard CLAUSE is
347
+ // added before the function's first equation, rejecting a parent-directory segment or an absolute path, and falls through to the
348
+ // existing equations otherwise, so no line of the body moves. The check is the one the taint engine's guard recogniser already
349
+ // understands (`".." \`elem\` splitDirectories p`), so the rescan judges the same semantics, not a name. Only a function with exactly
350
+ // one String/FilePath parameter and a `Maybe` result (bare, or under one monad) is rewritten: `Nothing` is the only failure value the
351
+ // fixer can construct without inventing an error type. A MITIGATION: a symbolic link inside the base can still point outside it.
352
+ function fixPathContain(text, f) {
353
+ const sig = signatureAt(text, f.line);
354
+ if (!sig) return null;
355
+ const pathParams = sig.params.filter((p, i) => /^(?:String|FilePath)$/.test(sig.types[i] || ''));
356
+ if (pathParams.length !== 1) return null;
357
+ const param = pathParams[0];
358
+ const result = sig.types[sig.types.length - 1] || '';
359
+ const rm = /^(?:(?:[A-Z][\w'.]*\s+)?\(\s*Maybe\s+[^()]+\)|Maybe\s+[^()\s]+(?:\s+[^()\s]+)*)$/.exec(result.trim());
360
+ if (!rm) return null;
361
+ const monadic = /^[A-Z]/.test(result.trim()) && !/^Maybe\b/.test(result.trim());
362
+ const fail = monadic ? 'pure Nothing' : 'Nothing';
363
+ const lines = text.split('\n');
364
+ // the first equation of this function (the clause enclosing the line may be a later one)
365
+ let first = sig.clause; for (let i = 0; i < sig.clause; i++) if (new RegExp(`^${sig.name}(?![\\w'])`).test(lines[i]) && !new RegExp(`^${sig.name}\\s*::`).test(lines[i])) { first = i; break; }
366
+ if (new RegExp(`splitDirectories\\s+${param}(?![\\w'])`).test(stripComments(text))) return null; // already checked somewhere
367
+ const imp = ensureImport(text, 'System.FilePath', ['splitDirectories', 'isAbsolute']); if (!imp) return null;
368
+ const guard = `${sig.name} ${param}\n | ".." \`elem\` ${imp.prefix}splitDirectories ${param} || ${imp.prefix}isAbsolute ${param} = ${fail}`;
369
+ lines.splice(first, 0, guard);
370
+ const next = applyImportDelta(text, imp.text, lines.join('\n'));
371
+ return {
372
+ ruleId: 'hs-path-contain', label: 'MITIGATION', text: next,
373
+ explanation: `Added a guard equation to \`${sig.name}\` that answers ${fail} when \`${param}\` has a ".." segment or is absolute, before any file operation runs; every other call falls through to the existing equations unchanged.`,
374
+ consequences: [
375
+ `A request naming a parent directory or an absolute path now gets the function's ${fail} (its not-found answer) instead of the file; a symbolic link inside the served directory can still point outside it (resolve with canonicalizePath and compare the prefix when links are possible).`,
376
+ 'The check is on the parameter as received: a path decoded or joined later inside the function is not re-checked.',
377
+ ],
378
+ behavior: { kind: 'path-contain', fn: sig.name, param },
379
+ expectEffective: { kind: 'path-contain', fn: sig.name, param, fail },
380
+ };
306
381
  }
307
382
 
308
383
  function fixPath(text, f) {
@@ -336,17 +411,46 @@ function fixPath(text, f) {
336
411
  }
337
412
 
338
413
  const LOG_CALLS = ['putStrLn', 'putStr', 'hPutStrLn', 'hPutStr', 'print', 'trace', 'traceShow', 'traceShowId', 'logInfoN', 'logDebugN', 'logWarnN', 'logErrorN', 'logInfo', 'logDebug', 'logWarn', 'logError'];
414
+ // hxt (0.195.16): `withSubstDTDEntities yes` (or `True`) on the reported line becomes `no` (`False`): the parser no longer substitutes
415
+ // entities declared in a DTD, which is the whole of the finding, so the label is FULL. `yes`/`no` come from Text.XML.HXT.Core (hxt
416
+ // 9.3.1.22 re-exports the XmlState type definitions); an explicit import list that names `yes` but not `no` gets `no` added.
417
+ function fixXmlEntities(text, f) {
418
+ const lb = lineBounds(text, f.line);
419
+ const m = /(?<![\w'.])((?:[A-Z][\w']*\.)?)withSubstDTDEntities\s+((?:[A-Z][\w']*\.)?)(yes|True)(?![\w'])/.exec(lb.text);
420
+ if (!m) return null;
421
+ const [, , qual, val] = m;
422
+ const to = val === 'True' ? 'False' : `${qual}no`;
423
+ let t = text;
424
+ if (val === 'yes' && !qual) {
425
+ const info = importInfo(text, 'Text.XML.HXT.Core');
426
+ if (info && !info.qualified && info.items && !info.items.some((x) => x.trim() === 'no')) { const e = ensureImport(text, 'Text.XML.HXT.Core', ['no']); if (!e) return null; t = e.text; }
427
+ }
428
+ const shifted = t.split('\n').length - text.split('\n').length;
429
+ const lb2 = lineBounds(t, f.line + shifted);
430
+ if (lb2.text !== lb.text) return null;
431
+ const at = lb2.start + m.index + m[0].length - val.length;
432
+ const next = splice(t, at, at + val.length, to);
433
+ return {
434
+ ruleId: 'hs-xml-no-entity-substitution', label: 'FULL', text: next,
435
+ explanation: `withSubstDTDEntities now reads ${to}: entities declared in a document's DTD are no longer substituted, so an external or recursive entity in untrusted XML is not expanded by this parser configuration.`,
436
+ consequences: ['A document that relies on its own DTD entities for content now yields the entity references unexpanded; a document that needs them must be parsed with a separate, trusted configuration.'],
437
+ behavior: { kind: 'hxt-entities', line: f.line + shifted },
438
+ expectEffective: { kind: 'hxt-entities', line: f.line + shifted },
439
+ };
440
+ }
339
441
  function fixLogging(text, f) {
340
442
  const lb = lineBounds(text, f.line);
341
443
  const call = findCall(lb.text, LOG_CALLS);
342
444
  if (!call) return null;
343
445
  const head = lb.text.slice(0, call.after); let rest = lb.text.slice(call.after);
344
446
  let n = 0;
345
- rest = rest.replace(/(?<![\w'.])(?:show\s+)?((?:[A-Za-z_][\w']*\.)?[a-z_][\w']*)(?![\w'(])/g, (all, id) => {
447
+ // Only code is rewritten: a string literal is kept as it is (before 0.195.16 the word inside `" password="` was replaced too, which
448
+ // produced an unparseable line; the guide's captured example showed it).
449
+ rest = rest.split(/("(?:[^"\\]|\\.)*")/).map((seg, i) => (i % 2 === 1 ? seg : seg.replace(/(?<![\w'.])(?:show\s+)?((?:[A-Za-z_][\w']*\.)?[a-z_][\w']*)(?![\w'(])/g, (all, id) => {
346
450
  const bare = id.replace(/^.*\./, '');
347
451
  if (/^(?:show|pack|unpack|stderr|stdout)$/.test(bare) || !SENSITIVE.test(bare)) return all;
348
452
  n++; return '"[REDACTED]"';
349
- });
453
+ }))).join('');
350
454
  if (!n) return null;
351
455
  const next = splice(text, lb.start, lb.end, head + rest);
352
456
  return { ruleId: 'hs-log-redact', label: 'MITIGATION', text: next, explanation: `Replaced ${n} sensitive log argument(s) with "[REDACTED]". The value is no longer logged; the log line loses that detail.`, behavior: { kind: 'log', redacted: n } };
@@ -408,8 +512,52 @@ export function tlsSettingsOf(text) {
408
512
  return out;
409
513
  }
410
514
 
515
+ // HsOpenSSL (0.195.16, from IHP's OpenAI client): `contextSetVerificationMode ctx VerifyNone` as a statement of a do block becomes
516
+ // `contextSetDefaultVerifyPaths ctx` (the system CA locations; HsOpenSSL 0.11.7.11 documents it as "the default locations from which CA
517
+ // certificates are loaded") followed by `contextSetVerificationMode ctx (VerifyPeer True True Nothing)` (a certificate is required,
518
+ // requested once, no callback). A MITIGATION: the chain is now verified against the system store, but HsOpenSSL checks the host name
519
+ // only through `enableHostnameValidation` on the SSL connection object, which this context-level code never sees, so a valid
520
+ // certificate for another host is still accepted; the consequence says so.
521
+ function fixOpenSslVerify(text, f) {
522
+ const info = importInfo(text, 'OpenSSL.Session');
523
+ if (!info || info.hiding) return null;
524
+ const STMT = /^(\s+)((?:[A-Z][\w']*\.)?)contextSetVerificationMode\s+([a-z_][\w']*)\s+((?:[A-Z][\w']*\.)?)VerifyNone\s*$/;
525
+ const lines = text.split('\n');
526
+ const okQual = (qual, qual2) => (!qual || (info.qualified && (info.alias || 'OpenSSL.Session') + '.' === qual)) && (!qual2 || qual2 === qual);
527
+ // a statement: the nearest less-indented preceding non-blank line opens a do block (`\ctx -> do`, `= do`, `do`)
528
+ const isStatement = (i, indent) => { for (let j = i - 1; j >= 0; j--) { const l = lines[j]; if (!l.trim()) continue; const ind = (/^\s*/.exec(l) || [''])[0].length; if (ind < indent.length) return /\bdo\s*$/.test(stripComments(l)); } return false; };
529
+ // The reported line first; every other VerifyNone statement of the same shape in the file is rewritten with it (one logical fix: the
530
+ // rule reports the class once per file, so a second site left as it was would keep the finding and block the patch for a reason the
531
+ // reader could not see). A VerifyNone the shape does not cover stays, and the rescan then says so.
532
+ const m0 = STMT.exec(lines[f.line - 1] || '');
533
+ if (!m0 || !okQual(m0[2], m0[4]) || !isStatement(f.line - 1, m0[1])) return null;
534
+ const sites = [];
535
+ lines.forEach((l, i) => { const m = STMT.exec(l); if (m && okQual(m[2], m[4]) && isStatement(i, m[1])) sites.push({ i, indent: m[1], ctx: m[3], qual: m[2] }); });
536
+ let t = text; let prefix = m0[2];
537
+ if (!m0[2]) { const e = ensureImport(text, 'OpenSSL.Session', ['contextSetDefaultVerifyPaths', 'contextSetVerificationMode', 'VerificationMode(..)']); if (!e) return null; t = e.text; prefix = e.prefix; }
538
+ const shifted = t.split('\n').length - lines.length;
539
+ const out = t.split('\n');
540
+ for (const s of [...sites].reverse()) {
541
+ if (out[s.i + shifted] !== lines[s.i]) return null;
542
+ out.splice(s.i + shifted, 1, `${s.indent}${prefix}contextSetDefaultVerifyPaths ${s.ctx}`, `${s.indent}${prefix}contextSetVerificationMode ${s.ctx} (${prefix}VerifyPeer True True Nothing)`);
543
+ }
544
+ // the mode statement of each site in the patched file: the import shift, plus one line per site above it
545
+ const modeLines = sites.map((s, k) => s.i + shifted + k + 2);
546
+ const next = out.join('\n');
547
+ const where = sites.length === 1 ? 'The OpenSSL context' : `Each of the ${sites.length} OpenSSL contexts set up in this file`;
548
+ return {
549
+ ruleId: 'hs-tls-validate', label: 'MITIGATION', text: next,
550
+ explanation: `${where} now loads the system CA locations (contextSetDefaultVerifyPaths) and requires a peer certificate that verifies against them (VerifyPeer True True Nothing) instead of VerifyNone${sites.length > 1 ? ` (${sites.length} sites, one per client function; the rule reports the class once per file)` : ''}.`,
551
+ consequences: [
552
+ 'Connections to a server whose certificate is self-signed, expired or issued by an authority missing from the system store now fail.',
553
+ 'The host name is NOT verified by this change: HsOpenSSL checks it only through enableHostnameValidation on the SSL connection object, which this context-level code does not have, so a valid certificate issued for another host is still accepted.',
554
+ ],
555
+ behavior: { kind: 'tls-openssl', sites: sites.map((s, k) => ({ ctx: s.ctx, line: modeLines[k] })) },
556
+ expectEffective: { kind: 'tls-openssl', sites: sites.map((s, k) => ({ ctx: s.ctx, line: modeLines[k] })) }, // each mode statement; the paths statement is the line above it
557
+ };
558
+ }
411
559
  function fixTls(text, f) {
412
- if (!importInfo(text, 'Network.Connection') && !importInfo(text, 'Network.Connection.Types')) return null; // the detector's own precondition
560
+ if (!importInfo(text, 'Network.Connection') && !importInfo(text, 'Network.Connection.Types')) return fixOpenSslVerify(text, f); // the detector's own precondition
413
561
  const lb = lineBounds(text, f.line);
414
562
  const m = new RegExp(TLS_CTOR_RE.source).exec(lb.text);
415
563
  if (!m) return null;
@@ -616,16 +764,85 @@ function fixCookie(text, f) {
616
764
  // and ONLY when the file already imports System.Entropy (the dependency is therefore already declared). `randomRIO (lo, hi)`, a range, a generator
617
765
  // threaded through other code, a non-integer result and every other shape are refused: the result type of an OS CSPRNG differs from System.Random's.
618
766
  const FIXED_WIDTH = { Word8: 1, Word16: 2, Word32: 4, Word64: 8, Word: 8, Int8: 1, Int16: 2, Int32: 4, Int64: 8, Int: 8 };
767
+ // The three shapes of 0.195.16 (each from a real site labelled TP: scotty's session id, gitit's session key, hackage-server's digest nonce):
768
+ // replicateM N (randomRIO ('a', 'z')) -> N bytes, each mapped into the character range
769
+ // randomRIO (lo, hi) (integer literals) -> 8 bytes decoded big-endian, reduced into the range
770
+ // (take N . map f . randomRs (lo, hi)) <$> newStdGen -> N bytes, each reduced into the range and mapped by f
771
+ // The reduction is `b mod (hi - lo + 1)`: for a range that does not divide the byte space (26 letters, 10^9) the low values are chosen slightly
772
+ // more often (at most 2% for 26 of 256, negligible for 10^9 of 2^64), stated in the consequences; a rejection loop would need a different
773
+ // program shape. `System.Entropy` is imported when missing, which makes the patch a MITIGATION: the `entropy` package must be declared by
774
+ // the component, and only a build checks that.
775
+ const ENT_DECODE = "AgenticBS.foldl' (\\acc b -> acc * 256 + fromIntegral b) (0 :: Integer)";
776
+ function fixRandomLine(text, f) {
777
+ const lb = lineBounds(text, f.line);
778
+ const line = lb.text;
779
+ const chr = (s) => (s.length === 3 ? s[1] : s === "'\\''" ? "'" : null);
780
+ let m; let repl = null; let bytes = 0; let what = '';
781
+ const used = [];
782
+ if ((m = /(?<![\w'.])replicateM\s+(\d+)\s+\(\s*(?:System\.Random\.)?randomRIO\s+\(\s*('(?:[^'\\]|\\.)')\s*,\s*('(?:[^'\\]|\\.)')\s*\)\s*\)/.exec(line)) && chr(m[2]) !== null && chr(m[3]) !== null) {
783
+ bytes = Number(m[1]); used.push('randomRIO');
784
+ repl = `(map (\\b -> toEnum (fromEnum ${m[2]} + fromIntegral b \`mod\` (fromEnum ${m[3]} - fromEnum ${m[2]} + 1))) . AgenticBS.unpack <$> getEntropy ${bytes})`;
785
+ what = `${bytes} characters in the range ${m[2]} to ${m[3]}`;
786
+ } else if ((m = /(?<![\w'.])(?:System\.Random\.)?randomRIO\s+\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)/.exec(line))) {
787
+ const lo = BigInt(m[1]); const hi = BigInt(m[2]);
788
+ if (hi <= lo) return null;
789
+ bytes = 8; used.push('randomRIO');
790
+ repl = `((\\w -> ${m[1]} + fromIntegral (w \`mod\` ${(hi - lo + 1n).toString()})) . ${ENT_DECODE} <$> getEntropy 8)`;
791
+ what = `an integer in the range ${m[1]} to ${m[2]}`;
792
+ } else if ((m = /(?:fmap\s+\(\s*take\s+(\d+)\s*\.\s*map\s+([a-z_][\w'.]*)\s*\.\s*(?:System\.Random\.)?randomRs\s+\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)\s*\)\s+newStdGen|\(\s*take\s+(\d+)\s*\.\s*map\s+([a-z_][\w'.]*)\s*\.\s*(?:System\.Random\.)?randomRs\s+\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)\s*\)\s*<\$>\s*newStdGen)/.exec(line))) {
793
+ const n = m[1] || m[5]; const fn = m[2] || m[6]; const lo = BigInt(m[3] || m[7]); const hi = BigInt(m[4] || m[8]);
794
+ if (hi <= lo) return null;
795
+ bytes = Number(n); used.push('randomRs', 'newStdGen');
796
+ repl = `(map (${fn} . (\\b -> ${lo.toString()} + fromIntegral b \`mod\` ${(hi - lo + 1n).toString()})) . AgenticBS.unpack <$> getEntropy ${bytes})`;
797
+ what = `${bytes} values in the range ${lo} to ${hi} mapped by ${fn}`;
798
+ }
799
+ if (!repl || !(bytes > 0)) return null;
800
+ if (/\bAgenticBS\b/.test(text)) return null;
801
+ const entropyWasImported = !!importInfo(text, 'System.Entropy');
802
+ const e1 = ensureImport(text, 'System.Entropy', ['getEntropy']); if (!e1) return null;
803
+ let t2 = e1.text;
804
+ const lines = t2.split('\n'); let at = -1; stripComments(t2).split('\n').forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
805
+ if (at < 0) return null;
806
+ lines.splice(at + 1, 0, 'import qualified Data.ByteString as AgenticBS');
807
+ t2 = lines.join('\n');
808
+ const newLineNo = f.line + (entropyWasImported ? 1 : 2); // the edited line moved down by the import(s) inserted above it
809
+ const lb2 = lineBounds(t2, newLineNo);
810
+ if (lb2.text !== line) return null;
811
+ const newLine = line.slice(0, m.index) + repl + line.slice(m.index + m[0].length);
812
+ let out = splice(t2, lb2.start, lb2.end, newLine);
813
+ // drop the System.Random names the file no longer uses (an unused import is an error under -Werror); the import goes when nothing is left
814
+ const code = () => out.split('\n').filter((l) => !/^import\s/.test(l)).join('\n');
815
+ const ri = importInfo(out, 'System.Random');
816
+ if (ri && !ri.qualified && ri.items) {
817
+ const keep = ri.items.filter((x) => !used.includes(x.replace(/\(.*$/, '').trim()) || new RegExp(`(?<![\\w'])${x.replace(/\(.*$/, '').trim()}(?![\\w'])`).test(code()));
818
+ if (!keep.length) out = out.replace(ri.line + '\n', '');
819
+ else if (keep.length !== ri.items.length) out = out.replace(ri.line, ri.line.replace(/\((.*)\)/, `(${keep.join(', ')})`));
820
+ }
821
+ const fnName = (() => { const ls = stripComments(text).split('\n'); let name = null; for (let i = 0; i < f.line; i++) { const mm = TOP_DECL.exec(ls[i] || ''); if (mm && !NOT_A_DEF.has(mm[1])) name = mm[1]; } return name; })();
822
+ const finalLine = out.split('\n').indexOf(newLine) + 1; // where the edited line sits once the unused import above it may have gone
823
+ if (finalLine < 1) return null;
824
+ return {
825
+ ruleId: 'hs-random-os-entropy', label: entropyWasImported ? 'FULL' : 'MITIGATION', text: out,
826
+ explanation: `${fnName || 'the action'} now draws ${what} from the operating system CSPRNG (System.Entropy.getEntropy, ${bytes} byte(s)) instead of System.Random's deterministic generator.`,
827
+ consequences: [
828
+ `Requires the \`entropy\` and \`bytestring\` packages to be declared by the component${entropyWasImported ? ' (this file already imported System.Entropy, so the first is)' : ' (the import of System.Entropy was ADDED: add `entropy` to build-depends)'}; the build is not checked against the cabal file.`,
829
+ 'Values are reduced into the range with a modulus, so for a range that does not divide the byte space the low values are drawn slightly more often (at most 2% for 26 letters over 256, negligible for an integer range over 2^64); the generated value is no longer predictable from a seed, which is what the finding is about.',
830
+ ],
831
+ behavior: { kind: 'random-line', fn: fnName, line: finalLine, bytes },
832
+ expectEffective: { kind: 'random-line', line: finalLine, bytes },
833
+ };
834
+ }
619
835
  function fixRandom(text, f) {
620
836
  const lb = lineBounds(text, f.line);
621
837
  const m = /^([a-z_][\w']*)\s*=\s*(?:System\.Random\.)?randomIO\s*$/.exec(lb.text);
622
- if (!m) return null;
838
+ if (!m) return fixRandomLine(text, f);
623
839
  const name = m[1];
624
840
  const sig = new RegExp(`^${name}\\s*::\\s*IO\\s+(${Object.keys(FIXED_WIDTH).join('|')})\\s*$`, 'm').exec(text);
625
841
  if (!sig) return null;
626
842
  if ((text.match(new RegExp(`^${name}\\s*=`, 'gm')) || []).length !== 1) return null;
627
843
  const bytes = FIXED_WIDTH[sig[1]];
628
- const ent = importInfo(text, 'System.Entropy'); if (!ent || ent.hiding) return null;
844
+ // Since 0.195.16 the import is added when missing (a MITIGATION: `entropy` must then be declared by the component).
845
+ const ent = importInfo(text, 'System.Entropy'); if (ent && ent.hiding) return null;
629
846
  const e1 = ensureImport(text, 'System.Entropy', ['getEntropy']); if (!e1) return null;
630
847
  if (/(?<![\w'.])AgenticBS\./.test(text)) return null;
631
848
  let t2 = e1.text;
@@ -642,8 +859,8 @@ function fixRandom(text, f) {
642
859
  if (!stillUsed && ri && !ri.qualified && ri.items && ri.items.length === 1 && ri.items[0] === 'randomIO') out = out.replace(ri.line + '\n', '');
643
860
  else if (!stillUsed && ri && !ri.qualified && ri.items && ri.items.includes('randomIO')) out = out.replace(ri.line, ri.line.replace(/\((.*)\)/, (_, inner) => `(${ri.items.filter((x) => x !== 'randomIO').join(', ')})`));
644
861
  return {
645
- ruleId: 'hs-random-os-entropy', label: 'FULL', text: out,
646
- explanation: `${name} now reads ${bytes} byte(s) from the operating system CSPRNG (System.Entropy.getEntropy) and decodes them big-endian into ${sig[1]}, instead of System.Random's deterministic generator.`,
862
+ ruleId: 'hs-random-os-entropy', label: ent ? 'FULL' : 'MITIGATION', text: out,
863
+ explanation: `${name} now reads ${bytes} byte(s) from the operating system CSPRNG (System.Entropy.getEntropy) and decodes them big-endian into ${sig[1]}, instead of System.Random's deterministic generator.${ent ? '' : ' The import of System.Entropy was added: add `entropy` to the component\'s build-depends.'}`,
647
864
  consequences: [`Requires the \`entropy\` and \`bytestring\` packages to be declared by the component (this file already imports System.Entropy, so the first is); the build is not checked against the cabal file.`, 'The value is uniform over the full range of its type; a caller that relied on a smaller range or on a seeded sequence changes behavior.'],
648
865
  behavior: { kind: 'random', fn: name, type: sig[1], bytes },
649
866
  expectEffective: { kind: 'random', fn: name, bytes },
@@ -1016,7 +1233,7 @@ function fixScottyBody(text, f) {
1016
1233
 
1017
1234
  const FIXERS = [
1018
1235
  { id: 'hs-scotty-body-limit', applies: (f) => f.rule === 'hs-unbounded-request-body' && /Web\.Scotty(?:\.Trans)?\.body\b/.test(String(f.description || f.detail || '')), run: fixScottyBody, reportedLineOnly: true },
1019
- { id: 'hs-password-kdf-migrate', applies: (f) => f.cwe === 'CWE-916' && (f.rule === 'hs-fast-password-hash' || /fast general-purpose hash/i.test(String(f.vuln || ''))), run: fixPasswordKdf },
1236
+ { id: 'hs-password-kdf-migrate', applies: (f) => f.cwe === 'CWE-916' && (f.rule === 'hs-fast-password-hash' || f.rule === 'hs-pbkdf2-low-iterations' || /fast general-purpose hash|too few iterations/i.test(String(f.vuln || ''))), run: fixPasswordKdf },
1020
1237
  { id: 'hs-tls-validate', applies: (f) => f.cwe === 'CWE-295' || f.rule === 'hs-tls-validation-disabled', run: fixTls, reportedLineOnly: true },
1021
1238
  { id: 'hs-cookie-attributes', applies: (f) => f.cwe === 'CWE-1004' || f.rule === 'hs-insecure-cookie', run: fixCookie, reportedLineOnly: true },
1022
1239
  { id: 'hs-random-os-entropy', applies: (f) => f.cwe === 'CWE-338' || f.rule === 'hs-predictable-random', run: fixRandom, reportedLineOnly: true },
@@ -1027,7 +1244,9 @@ const FIXERS = [
1027
1244
  { id: 'hs-html-escape-text', applies: (f) => f.cwe === 'CWE-79', run: fixHtmlText, reportedLineOnly: true },
1028
1245
  { id: 'hs-hash-sha256', applies: (f) => f.cwe === 'CWE-328' || f.rule === 'hs-weak-hash', run: fixWeakHash },
1029
1246
  { id: 'hs-log-redact', applies: (f) => f.cwe === 'CWE-532' || f.rule === 'hs-sensitive-logging', run: fixLogging },
1247
+ { id: 'hs-xml-no-entity-substitution', applies: (f) => f.cwe === 'CWE-611' || f.rule === 'hs-xml-entity-substitution', run: fixXmlEntities, reportedLineOnly: true },
1030
1248
  { id: 'hs-path-takefilename', applies: (f) => f.cwe === 'CWE-22', run: fixPath },
1249
+ { id: 'hs-path-contain', applies: (f) => f.cwe === 'CWE-22', run: fixPathContain, reportedLineOnly: true },
1031
1250
  ];
1032
1251
  export const SUPPORTED_FIX_IDS = Object.freeze(FIXERS.map((x) => x.id));
1033
1252
 
@@ -1098,18 +1317,19 @@ const DEFAULT_CONSEQUENCES = Object.freeze({
1098
1317
 
1099
1318
  // Classes whose repair needs a choice the code does not contain (a policy, a library, a limit). The scanner reports them; it does not guess.
1100
1319
  // Each reason ends with "The decision needed: ..." naming what the operator has to decide; planHaskellFix returns that part as `decision`.
1101
- const CLASS_REASONS = Object.freeze({
1320
+ export const CLASS_REASONS = Object.freeze({
1102
1321
  'CWE-918': 'server-side request forgery has no line-level repair: a check on the URL string is bypassed by redirects, alternate encodings and DNS. The decision needed: which hosts or networks this service may call (an allow-list of exact hosts, or an address policy that rejects loopback, link-local and private ranges after name resolution and on every redirect).',
1322
+ 'CWE-78': 'a shell command string is replaced by a direct call of the program with an argument list. The recognised shape is callCommand/system/spawnCommand of a ++ or <> concatenation (String, or Text under cs/unpack, on the line or in a one-line let of the same function) whose first word is a string literal naming the program. The decision needed: when the program itself is a variable (an editor or tool chosen through an environment variable, say), how that value splits into a program and its own arguments, because a configured command such as "code --goto" is not one program name; and for an argument built with if/case or a function call, the exact argument list.',
1103
1323
  'CWE-88': 'argument injection is repaired by a rule about what the called program accepts; inserting "--" only helps programs that honour it, so no patch is made. The decision needed: the permitted values for this argument (a closed list or a strict pattern), or the option of that program that ends option parsing.',
1104
1324
  'CWE-916': 'password hashing needs a password KDF (Argon2id, scrypt, bcrypt, or PBKDF2 with a high iteration count) from a library the project may not declare as a dependency, and every stored hash was produced by the old function: swapping it makes every existing password fail to verify and locks the users out. The one shape rewritten is a migration: a pure one-line `name salt password = ...` of one text type (String, Text or strict ByteString) in a file that already imports cryptonite or crypton and Data.Text or Data.ByteString, whose comparisons all sit in this file; it keeps the old function for verification, adds PBKDF2-HMAC-SHA256 and a verify helper, and this is not that shape. The decision needed: which KDF and library, with which cost parameters, and the migration (verify the old hash once, then re-hash with the new KDF at the next successful login, or force a password reset).',
1105
- 'CWE-338': 'a predictable generator is replaced by the operating system CSPRNG (System.Entropy or Crypto.Random), whose result type is bytes, not System.Random\'s: the surrounding code must change with it. Only a one-line action of type IO <fixed-width integer> equal to randomIO, in a file that already imports System.Entropy, is rewritten. The decision needed: the token\'s type and size (a 30-bit integer cannot be made unguessable by changing the generator) and the entropy library the project declares.',
1325
+ 'CWE-338': 'a predictable generator is replaced by the operating system CSPRNG (System.Entropy or Crypto.Random), whose result type is bytes, not System.Random\'s: the surrounding code must change with it. Four shapes are rewritten (System.Entropy imported when missing, which makes the patch a MITIGATION until `entropy` is declared): a one-line action of type IO <fixed-width integer> equal to randomIO; `replicateM n (randomRIO (c1, c2))` over a character range; `randomRIO (lo, hi)` over integer literals; `(take n . map f . randomRs (lo, hi)) <$> newStdGen`. Any other shape (a generator threaded through other code, a non-literal range, a non-integer result) is not. The decision needed: the token\'s type and size (a 30-bit integer cannot be made unguessable by changing the generator) and the entropy library the project declares.',
1106
1326
  'CWE-1004': 'cookie attributes live in different places per framework. The recognised shapes are a Set-Cookie header string literal, a Web.Cookie SetCookie record update, servant-auth cookieIsSecure, and scotty setSimpleCookie/makeSimpleCookie; this is none of them, or an attribute is set from an expression. The decision needed: which attributes to send (Secure, HttpOnly, SameSite) and whether this cookie must work over plain HTTP.',
1107
1327
  'CWE-306': 'a missing authentication check needs a policy; the scanner reports the handler and does not invent the guard. The decision needed: which credential authenticates this route (session, token, API key) and what an unauthenticated caller receives (401, a redirect to login).',
1108
1328
  'CWE-770': 'a resource limit is a number only the operator can choose (how many bytes a body may have, how many requests, how large an allocation). Two shapes are recognised, each with a declared default: wai strictRequestBody of a Request variable in a file importing Network.Wai and Data.ByteString.Lazy, which gets a bounded reader, and scotty body in a file whose ScottyM block contains the read or names the handler, which gets setMaxRequestBodySize as that block\'s first statement (scotty 0.12 and later; refused when the block is in another file or a cabal bound excludes those versions); this is neither shape. The decision needed: the limit for this input, and what happens past it (reject with 413, truncate, spool to disk).',
1109
- 'CWE-295': 'enabling certificate validation can break connections the project depends on. Only the connection package\'s TLSSettingsSimple with a literal True is flipped. The decision needed: the trust store (the system store, a pinned CA bundle, a private authority) and host-name verification; an OpenSSL VerifyNone is not rewritten because VerifyPeer verifies nothing until a CA bundle is loaded and does not check the host name.',
1329
+ 'CWE-295': 'enabling certificate validation can break connections the project depends on. Only the connection package\'s TLSSettingsSimple with a literal True is flipped. An HsOpenSSL `contextSetVerificationMode ctx VerifyNone` statement is rewritten to load the default CA locations and require a verified peer (a mitigation: the host name is still unchecked). The decision needed: the trust store (the system store, a pinned CA bundle, a private authority) and host-name verification.',
1110
1330
  'CWE-798': 'a hard-coded secret must be rotated and moved to a secret store; rewriting the line would leave the leaked value valid. The decision needed: where the secret is read from at run time (environment, a secrets manager, a file outside the repository) and rotating the exposed value, which no edit of this file can do.',
1111
1331
  'CWE-611': 'XML entity handling depends on the parser configuration in use. The decision needed: whether this document source is trusted and which entity and DTD features the parser may enable.',
1112
- 'CWE-22': 'path traversal is repaired by confining the final path to a base directory. takeFileName is applied only to a String/FilePath parameter spliced under a fixed directory in a file operation; this shape (several segments, a base chosen at run time, a directory operation, a path handed through other functions) is not that. The decision needed: the directory the files must stay in, and whether a name containing separators or ".." is rejected or reduced to its last segment.',
1332
+ 'CWE-22': 'path traversal is repaired by confining the final path to a base directory. takeFileName is applied only to a String/FilePath parameter spliced under a fixed directory in a file operation, and a guard equation rejecting ".." segments and absolute paths is added only to a function with exactly one String/FilePath parameter and a Maybe result (bare or under one monad); this shape (a base chosen at run time, several path parameters, a result with no not-found value, a path handed through other functions) is neither. The decision needed: the directory the files must stay in, and whether a name containing separators or ".." is rejected or reduced to its last segment.',
1113
1333
  'CWE-79': 'output encoding depends on the context the value is written into (HTML text, attribute, script, URL) and on the library that renders it; only blaze-html\'s preEscapedToHtml and scotty\'s html of concatenated text are rewritten (the latter with an escaper defined in the file, for HTML text and quoted attribute values). The decision needed: render the value through the project\'s escaping HTML library, or send it as text/plain.',
1114
1334
  'CWE-601': 'an open redirect is repaired by restricting where the redirect may go. The decision needed: the permitted destinations (an allow-list of hosts, or relative paths only).',
1115
1335
  });
@@ -1227,6 +1447,37 @@ export function haskellEffectiveCheck(plan, patched) {
1227
1447
  if (!new RegExp(`getEntropy\\s+${want.bytes}\\s*$`).test(d)) return { ok: false, ran: true, detail: `the action does not read ${want.bytes} entropy byte(s)` };
1228
1448
  return { ok: true, ran: true, detail: `${want.fn} reads ${want.bytes} byte(s) from getEntropy and no longer calls System.Random` };
1229
1449
  }
1450
+ if (want.kind === 'tls-openssl') {
1451
+ const ls = stripComments(after).split('\n');
1452
+ for (const s of want.sites) {
1453
+ const set = ls[s.line - 1] || ''; const paths = ls[s.line - 2] || '';
1454
+ if (!new RegExp(`contextSetVerificationMode\\s+${s.ctx}\\s+\\((?:[A-Z][\\w']*\\.)?VerifyPeer True True Nothing\\)\\s*$`).test(set)) return { ok: false, ran: true, detail: `line ${s.line}: the context is not set to VerifyPeer True True Nothing` };
1455
+ if (!new RegExp(`contextSetDefaultVerifyPaths\\s+${s.ctx}\\s*$`).test(paths)) return { ok: false, ran: true, detail: `line ${s.line}: the context does not load the default CA locations before the mode is set` };
1456
+ }
1457
+ const none = (t) => (stripComments(t).match(/(?<![\w'])VerifyNone(?![\w'])/g) || []).length; // qualified uses count too
1458
+ if (none(after) !== none(before) - want.sites.length) return { ok: false, ran: true, detail: `the patch did not remove exactly ${want.sites.length} VerifyNone` };
1459
+ return { ok: true, ran: true, detail: `${want.sites.map((s) => s.ctx).join(', ')} load the default CA locations and require a verified peer certificate (${want.sites.length} site(s); host name not checked by this patch)` };
1460
+ }
1461
+ if (want.kind === 'hxt-entities') {
1462
+ const l = stripComments(after).split('\n')[want.line - 1] || '';
1463
+ if (!/(?<![\w'.])withSubstDTDEntities\s+(?:(?:[A-Z][\w']*\.)?no|False)(?![\w'])/.test(l)) return { ok: false, ran: true, detail: 'the line does not set withSubstDTDEntities to no' };
1464
+ const yes = (t) => (stripComments(t).match(/withSubstDTDEntities\s+(?:(?:[A-Z][\w']*\.)?yes|True)(?![\w'])/g) || []).length;
1465
+ if (yes(after) !== yes(before) - 1) return { ok: false, ran: true, detail: 'the patch did not turn off exactly one entity substitution' };
1466
+ return { ok: true, ran: true, detail: `line ${want.line} no longer substitutes DTD entities` };
1467
+ }
1468
+ if (want.kind === 'path-contain') {
1469
+ const re = new RegExp(`^${want.fn} ${want.param}\\n\\s+\\| "\\.\\." \`elem\` (?:[A-Z][\\w']*\\.)?splitDirectories ${want.param} \\|\\| (?:[A-Z][\\w']*\\.)?isAbsolute ${want.param} = ${want.fail.replace(/ /g, '\\s+')}\\s*$`, 'm');
1470
+ if (!re.test(stripComments(after))) return { ok: false, ran: true, detail: `no guard equation on ${want.fn} rejects a ".." segment or an absolute ${want.param}` };
1471
+ const eqs = (t) => stripComments(t).split('\n').filter((l) => new RegExp(`^${want.fn}(?![\\w'])`).test(l) && !new RegExp(`^${want.fn}\\s*::`).test(l)).length;
1472
+ if (eqs(after) !== eqs(before) + 1) return { ok: false, ran: true, detail: 'the patch did not add exactly one equation' };
1473
+ return { ok: true, ran: true, detail: `${want.fn} answers ${want.fail} for a ".." segment or an absolute ${want.param} before its body runs` };
1474
+ }
1475
+ if (want.kind === 'random-line') {
1476
+ const d = (stripComments(after).split('\n')[want.line - 1] || '');
1477
+ if (/randomRIO|randomRs|newStdGen|randomIO|System\.Random/.test(d)) return { ok: false, ran: true, detail: 'the line still calls System.Random' };
1478
+ if (!new RegExp(`getEntropy\\s+${want.bytes}(?![\\d])`).test(d)) return { ok: false, ran: true, detail: `the line does not read ${want.bytes} entropy byte(s)` };
1479
+ return { ok: true, ran: true, detail: `line ${want.line} reads ${want.bytes} byte(s) from getEntropy and no longer calls System.Random` };
1480
+ }
1230
1481
  if (want.kind === 'scotty-body-limit') {
1231
1482
  const count = (t, re) => (stripComments(t).match(re) || []).length;
1232
1483
  const limits = (t) => count(t, /(?<![\w'])(?:[A-Z][\w']*\.)?setMaxRequestBodySize(?![\w'])/g);
@@ -1321,8 +1321,9 @@ Limits: value versions come from per-function reaching definitions; mutation thr
1321
1321
  `haskell-view.js` rewrites a clone of each Haskell function so a monadic source read becomes a seedable member read on the
1322
1322
  synthetic root `$hs`, seeds record-parameter sources, and treats `length`/`null` as non-content. `nix-view.js` builds
1323
1323
  field-to-store flows from `language/nix-privacy.js` (a personal-data `config.<module>.<field>` interpolated into another option;
1324
- hashed, measured or compared references are protected and make no flow). Coverage reports `unknown` for both languages: no
1325
- taint-recall tier is borrowed. See `scanner/src/language/CLAUDE.md`.
1324
+ hashed, measured or compared references are protected and make no flow). Coverage reports tier `partial` for both languages (since 0.195.17; it was `unknown`), with NO recall number: the
1325
+ only measured Haskell figure is the nine self-authored entries of `docs/METRICS.md`, and showing it as "100% measured recall" in the impact
1326
+ report would read as a real-code measurement. No taint-recall tier is borrowed. See `scanner/src/language/CLAUDE.md`.
1326
1327
 
1327
1328
  ## Runtime twin additions (PRD 2 gap closure)
1328
1329
 
@@ -38,6 +38,14 @@ export const LANGUAGE_COVERAGE_TIERS = Object.freeze([
38
38
  Object.freeze({ language: 'php', tier: 'partial', irTaintRecallPct: 52, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
39
39
  Object.freeze({ language: 'kotlin', tier: 'partial', irTaintRecallPct: 48, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
40
40
  Object.freeze({ language: 'cpp', tier: 'partial', irTaintRecallPct: 18, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
41
+ // Haskell and Nix are wired into the lineage graph (haskell-view.js seeds request/record sources through the taint engine; nix-view.js
42
+ // builds field-to-store flows from the NixOS option analysis), so they are not "unknown". `partial` is the honest categorical claim: some
43
+ // flows are seen, the stated limits apply (docs/guides/haskell.md), and neither reaches `full`. NO recall number is attached, on purpose:
44
+ // docs/METRICS.md's Haskell row (9 of 9 by IR-TAINT) is a sample of nine self-authored entries, and shown as "100% measured recall" in the
45
+ // impact report it would read as a measurement of real code, which it is not (the real-code figure is precision on a fresh set, 21 of 51
46
+ // flagged sites true, bench/dag/dev-haskell). Nix has no taint recall at all: it is configuration reasoning, not a flow engine.
47
+ Object.freeze({ language: 'haskell', tier: 'partial', irTaintRecallPct: null, measuredAt: null, source: null }),
48
+ Object.freeze({ language: 'nix', tier: 'partial', irTaintRecallPct: null, measuredAt: null, source: null }),
41
49
  Object.freeze({ language: 'rust', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
42
50
  Object.freeze({ language: 'solidity', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
43
51
  Object.freeze({ language: 'swift', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
@@ -118,7 +118,8 @@ export const CWE_MAP = Object.freeze({
118
118
  'CWE-918': Object.freeze({ category: 'external-api', status: 'modeled', why: 'SSRF sinks are outbound HTTP client calls' }),
119
119
  // X-004 / haskell-models.js: untrusted text placed in a model request BODY. The call is an ordinary outbound HTTP
120
120
  // body setter, so it is an external-api destination; whether the destination is a model provider is decided by
121
- // the AI-evidence overlay (lineage/haskell-ai.js), never by this table.
121
+ // the technical provider catalog in recipient-registry.js (the destination host, e.g. an anthropic.com or openai.com
122
+ // base URL), never by this table.
122
123
  'CWE-1427': Object.freeze({ category: 'external-api', status: 'modeled', why: 'a model request body is an outbound HTTP request body' }),
123
124
  'CWE-601': Object.freeze({ category: 'http-response', status: 'modeled', why: 'a redirect is written as a response header' }),
124
125
  'CWE-113': Object.freeze({ category: 'http-response', status: 'modeled', why: 'response splitting — the sink IS the response header writer' }),