@clear-capabilities/agentic-security-scanner 0.189.0 → 0.191.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@ import { tmpdir } from 'node:os';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import { parseHaskell } from './haskell-parser.js';
21
21
  import { SENSITIVE } from './haskell-security-rules.js';
22
+ import { stripComments } from './haskell-comments.js';
22
23
  import { compareVersions, parseVersion } from './haskell-manifests.js';
23
24
  import { runFixLifecycle, unifiedDiff, writeWithBackup, undoFix } from './fix-lifecycle.js';
24
25
 
@@ -351,7 +352,362 @@ function fixLogging(text, f) {
351
352
  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 } };
352
353
  }
353
354
 
355
+ // ── TLS certificate validation (CWE-295) ─────────────────────────────────────
356
+ // One boolean literal: `TLSSettingsSimple True ..` / `{ settingDisableCertificateValidation = True }` of the `connection` package becomes False.
357
+ // API (checked against the package documentation): `data TLSSettings = TLSSettingsSimple { settingDisableCertificateValidation, settingDisableSession,
358
+ // settingUseServerName :: Bool } | TLSSettings ClientParams`. The OpenSSL `VerifyNone` shape is NOT rewritten: `VerifyPeer` needs a CA bundle to be
359
+ // loaded and a host-name check, which are deployment decisions (see CLASS_REASONS).
360
+ const TLS_FIELD = 'settingDisableCertificateValidation';
361
+ const TLS_CTOR_RE = /(?<![\w'.])(?:[A-Z][\w']*\.)?TLSSettingsSimple(?![\w'])/g;
362
+
363
+ /** Index after the `}` matching the `{` at s[i] (strings skipped), or -1. */
364
+ function braceEnd(s, i) {
365
+ let depth = 0;
366
+ for (let j = i; j < s.length; j++) {
367
+ if (s[j] === '"') { const e = atomEnd(s, j); if (e < 0) return -1; j = e - 1; continue; }
368
+ if (s[j] === '{') depth++; else if (s[j] === '}' && --depth === 0) return j + 1;
369
+ }
370
+ return -1;
371
+ }
372
+ /** Splits `a (b c) "d"` at top-level whitespace into atoms. */
373
+ function wordsOf(s) {
374
+ const out = []; let i = 0;
375
+ while (i < s.length) {
376
+ while (i < s.length && /\s/.test(s[i])) i++;
377
+ if (i >= s.length) break;
378
+ const e = atomEnd(s, i);
379
+ if (e < 0) return null;
380
+ out.push(s.slice(i, e).trim()); i = e;
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * The structural reading of every `TLSSettingsSimple` construction in `text`: [{start, form, disableCertificateValidation: true|false|null, others}].
387
+ * null means "not a literal". This is the behavior model of the TLS fixer AND the independent reader its effective-change check uses.
388
+ */
389
+ export function tlsSettingsOf(text) {
390
+ const code = stripComments(text);
391
+ const out = [];
392
+ for (const m of code.matchAll(TLS_CTOR_RE)) {
393
+ let i = m.index + m[0].length;
394
+ while (/\s/.test(code[i] || '')) i++;
395
+ const lit = (v) => { const t = trimParens(v); return t === 'True' ? true : t === 'False' ? false : null; };
396
+ if (code[i] === '{') {
397
+ const end = braceEnd(code, i);
398
+ if (end < 0) { out.push({ start: m.index, form: 'record', disableCertificateValidation: null, others: [] }); continue; }
399
+ const fields = {};
400
+ for (const part of (topLevelSplit(code.slice(i + 1, end - 1), ',') || [])) { const kv = /^\s*([\w']+)\s*=\s*([\s\S]*?)\s*$/.exec(part); if (kv) fields[kv[1]] = kv[2]; }
401
+ out.push({ start: m.index, form: 'record', disableCertificateValidation: fields[TLS_FIELD] === undefined ? null : lit(fields[TLS_FIELD]), others: ['settingDisableSession', 'settingUseServerName'].map((k) => fields[k] ?? null) });
402
+ } else {
403
+ const lineEnd = code.indexOf('\n', i); const rest = code.slice(i, lineEnd < 0 ? code.length : lineEnd);
404
+ const ws = wordsOf(rest) || [];
405
+ out.push({ start: m.index, form: 'positional', disableCertificateValidation: ws.length >= 1 ? lit(ws[0]) : null, others: ws.slice(1, 3) });
406
+ }
407
+ }
408
+ return out;
409
+ }
410
+
411
+ function fixTls(text, f) {
412
+ if (!importInfo(text, 'Network.Connection') && !importInfo(text, 'Network.Connection.Types')) return null; // the detector's own precondition
413
+ const lb = lineBounds(text, f.line);
414
+ const m = new RegExp(TLS_CTOR_RE.source).exec(lb.text);
415
+ if (!m) return null;
416
+ let i = lb.start + m.index + m[0].length;
417
+ while (/\s/.test(text[i] || '')) i++;
418
+ let at = -1; let form;
419
+ if (text[i] === '{') {
420
+ const end = braceEnd(text, i); if (end < 0) return null;
421
+ const hits = [...text.slice(i, end).matchAll(new RegExp(`(?<![\\w'.])${TLS_FIELD}\\s*=\\s*(True)(?![\\w'.])`, 'g'))];
422
+ if (hits.length !== 1) return null;
423
+ at = i + hits[0].index + hits[0][0].lastIndexOf('True'); form = 'record';
424
+ } else {
425
+ const p = /^(\(\s*)?True(?![\w'.])/.exec(text.slice(i, i + 40));
426
+ if (!p) return null;
427
+ at = i + (p[1] ? p[1].length : 0); form = 'positional';
428
+ }
429
+ const next = splice(text, at, at + 4, 'False');
430
+ const before = tlsSettingsOf(text); const after = tlsSettingsOf(next);
431
+ const ordinal = before.filter((s) => s.start <= at).length - 1;
432
+ return {
433
+ ruleId: 'hs-tls-validate', label: 'FULL', text: next,
434
+ explanation: `Set ${TLS_FIELD} to False (${form} form): TLS certificate validation is on again for this connection; nothing else in the settings changed.`,
435
+ consequences: ['Connections to a server whose certificate is self-signed, expired, issued by an authority missing from the system trust store, or issued for another host name now fail. A private certificate authority must be added to the system trust store (or the settings built with TLSSettings and an explicit ClientParams) before this is deployed.'],
436
+ behavior: { kind: 'tls', form, setting: TLS_FIELD, from: true, to: false },
437
+ expectEffective: { kind: 'tls', ordinal, siteCount: before.length, expectSites: after.length },
438
+ };
439
+ }
440
+
441
+ // ── cookie attributes (CWE-1004) ──────────────────────────────────────────────
442
+ // Four framework shapes, each a table row: a hand-written Set-Cookie header literal, a `Web.Cookie` SetCookie record update, servant-auth's
443
+ // `cookieIsSecure = NotSecure`, and scotty's `setSimpleCookie` / `makeSimpleCookie` (which build a cookie with neither attribute).
444
+ // APIs (checked against the package documentation): cookie `SetCookie { setCookieHttpOnly, setCookieSecure :: Bool, .. }`;
445
+ // servant-auth-server `CookieSettings { cookieIsSecure :: IsSecure }`, `data IsSecure = Secure | NotSecure`; scotty's `Web.Scotty.Cookie` re-exports
446
+ // `setCookie`, `makeSimpleCookie`, `setCookieHttpOnly`, `setCookieSecure`. SameSite is NOT touched.
447
+ const COOKIE_CONSEQ_SECURE = 'Secure: the browser sends the cookie over HTTPS only. A plain-HTTP deployment (or local development over HTTP, in browsers that do not exempt localhost) stops keeping the session.';
448
+ const COOKIE_CONSEQ_HTTPONLY = 'HttpOnly: page scripts can no longer read the cookie through document.cookie; code that does must be changed.';
449
+ const SET_COOKIE_KEY = /^(?:(?:[\w.]*\.)?hSetCookie|"[Ss]et-[Cc]ookie"|(?:[\w.]*\.)?(?:pack|fromString)\s+"[Ss]et-[Cc]ookie")$/;
450
+
451
+ /** The module qualifier written just before `idx` (`C.` or `A.B.`), or ''. Walks back by hand: no nested-quantifier pattern. */
452
+ function qualifierBefore(s, idx) {
453
+ let i = idx;
454
+ while (i > 1 && s[i - 1] === '.') {
455
+ let j = i - 1;
456
+ while (j > 0 && /[\w']/.test(s[j - 1])) j--;
457
+ if (j === i - 1 || !/[A-Z]/.test(s[j])) break;
458
+ i = j;
459
+ }
460
+ return s.slice(i, idx);
461
+ }
462
+
463
+ /** Every place a cookie is configured, with what the code says about its attributes: [{kind, start, end, httpOnly, secure}] in source order. */
464
+ export function cookieSitesOf(text) {
465
+ const code = stripComments(text);
466
+ const sites = [];
467
+ // 1. a Set-Cookie header tuple on one line
468
+ for (const m of code.matchAll(/\(\s*((?:[\w.]*\.)?hSetCookie|"[Ss]et-[Cc]ookie"|(?:[\w.]*\.)?(?:pack|fromString)\s+"[Ss]et-[Cc]ookie")\s*,/g)) {
469
+ const end = atomEnd(code, m.index); if (end < 0 || code.slice(m.index, end).includes('\n')) continue;
470
+ const lits = [...code.slice(m.index, end).matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((x) => x[1]).join('');
471
+ sites.push({ kind: 'header', start: m.index, end, httpOnly: /;\s*HttpOnly\b/i.test(lits), secure: /;\s*Secure\b/i.test(lits) });
472
+ }
473
+ // 2. a record update that names setCookieName
474
+ for (const m of code.matchAll(/(?<![\w'])setCookieName\s*=/g)) {
475
+ let open = -1; let depth = 0;
476
+ for (let j = m.index - 1; j >= 0; j--) { if (code[j] === '}') depth++; else if (code[j] === '{') { if (depth === 0) { open = j; break; } depth--; } }
477
+ if (open < 0) continue;
478
+ const end = braceEnd(code, open); if (end < 0) continue;
479
+ const body = code.slice(open, end);
480
+ const flag = (name) => new RegExp(`(?<![\\w'])(?:[A-Z][\\w']*\\.)?${name}\\s*=\\s*True(?![\\w'.])`).test(body);
481
+ if (!sites.some((s) => s.kind === 'record' && s.start === open)) sites.push({ kind: 'record', start: open, end, httpOnly: flag('setCookieHttpOnly'), secure: flag('setCookieSecure') });
482
+ }
483
+ // 3. servant-auth
484
+ for (const m of code.matchAll(/(?<![\w'])cookieIsSecure\s*=\s*((?:[A-Z][\w']*\.)?(?:Not)?Secure)(?![\w'])/g)) sites.push({ kind: 'servant', start: m.index, end: m.index + m[0].length, httpOnly: true, secure: !/NotSecure$/.test(m[1]) });
485
+ // 4. scotty helpers; a call wrapped as `(makeSimpleCookie a b) { setCookieHttpOnly = True, setCookieSecure = True }` is already secured
486
+ for (const m of code.matchAll(/(?<![\w'.])((?:[A-Z][\w']*\.)?)(setSimpleCookie|makeSimpleCookie)(?![\w'])/g)) {
487
+ const a1 = atomEnd(code, m.index + m[0].length); const a2 = a1 < 0 ? -1 : atomEnd(code, a1);
488
+ if (a2 < 0) continue;
489
+ let after = a2; while (code[after] === ' ') after++;
490
+ let httpOnly = false; let secure = false; let end = a2;
491
+ if (m[2] === 'makeSimpleCookie' && code[after] === ')') {
492
+ let k = after + 1; while (code[k] === ' ') k++;
493
+ if (code[k] === '{') { const be = braceEnd(code, k); if (be > 0) { const body = code.slice(k, be); httpOnly = /setCookieHttpOnly\s*=\s*True(?![\w'.])/.test(body); secure = /setCookieSecure\s*=\s*True(?![\w'.])/.test(body); end = be; } }
494
+ }
495
+ sites.push({ kind: 'scotty-helper', start: m.index, end, httpOnly, secure });
496
+ }
497
+ return sites.sort((a, b) => a.start - b.start);
498
+ }
499
+
500
+ function lastLiteralAppend(valueExpr, missing) {
501
+ // `"a=" <> sid <> "; Path=/"` -> the final operand must be a string literal; the attributes are appended inside its quotes
502
+ const ops = splitOperators(valueExpr);
503
+ if (!ops || ops.length < 1) return null;
504
+ const last = ops[ops.length - 1];
505
+ const t = last.trim();
506
+ if (!STRING.test(t)) return null;
507
+ const body = litValue(t);
508
+ if (/;\s*$/.test(body) || /\\$/.test(body)) return null; // a trailing separator or escape: the join would be ambiguous
509
+ const idx = last.lastIndexOf(t);
510
+ const patchedLit = `"${body}${missing.map((a) => `; ${a}`).join('')}"`;
511
+ const ls = ops.slice(0, -1).join('') + last.slice(0, idx) + patchedLit + last.slice(idx + t.length);
512
+ return ls;
513
+ }
514
+
515
+ function fixCookie(text, f) {
516
+ const before = cookieSitesOf(text);
517
+ if (!before.length) return null;
518
+ const lb = lineBounds(text, f.line);
519
+ // the site the finding is on: the first insecure site that starts within the reported statement (the site may span lines)
520
+ const lineOf = (off) => text.slice(0, off).split('\n').length;
521
+ const site = before.filter((s) => (!s.httpOnly || !s.secure) && lineOf(s.end) >= f.line && lineOf(s.start) <= f.line + 8).sort((a, b) => a.start - b.start)[0];
522
+ if (!site) return null;
523
+ const missing = [];
524
+ if (!site.httpOnly) missing.push('HttpOnly'); if (!site.secure) missing.push('Secure');
525
+ const consequences = [...(missing.includes('Secure') ? [COOKIE_CONSEQ_SECURE] : []), ...(missing.includes('HttpOnly') ? [COOKIE_CONSEQ_HTTPONLY] : []), 'SameSite is not changed by this fix.'];
526
+ let next = null; let editAt = site.start; let how = site.kind;
527
+ if (site.kind === 'header') {
528
+ const seg = text.slice(site.start, site.end);
529
+ const inner = seg.slice(1, -1);
530
+ const parts = topLevelSplit(inner, ',');
531
+ if (!parts || parts.length !== 2 || !SET_COOKIE_KEY.test(parts[0].trim())) return null;
532
+ const val = lastLiteralAppend(parts[1], missing); if (val === null) return null;
533
+ next = splice(text, site.start, site.end, `(${parts[0]},${val})`);
534
+ } else if (site.kind === 'servant') {
535
+ const mm = /NotSecure/.exec(text.slice(site.start, site.end)); if (!mm) return null;
536
+ const imp = importInfo(text, 'Servant.Auth.Server');
537
+ if (!imp || imp.hiding || (imp.items !== null && !imp.items.some((x) => /^IsSecure\(\.\.\)$/.test(x.replace(/\s+/g, ''))))) return null; // `Secure` must already be in scope
538
+ next = splice(text, site.start + mm.index, site.start + mm.index + 'NotSecure'.length, 'Secure');
539
+ editAt = site.start + mm.index;
540
+ } else if (site.kind === 'record') {
541
+ const body = text.slice(site.start, site.end);
542
+ const q = qualifierBefore(body, body.search(/(?<![\w'])setCookieName\s*=/));
543
+ let b2 = body; const adds = [];
544
+ for (const [flag, field] of [[site.httpOnly, 'setCookieHttpOnly'], [site.secure, 'setCookieSecure']]) {
545
+ if (flag) continue;
546
+ const re = new RegExp(`(?<![\\w'])((?:[A-Z][\\w']*\\.)?${field})(\\s*=\\s*)(False)(?![\\w'.])`);
547
+ if (re.test(b2)) b2 = b2.replace(re, '$1$2True');
548
+ else if (new RegExp(`(?<![\\w'])(?:[A-Z][\\w']*\\.)?${field}\\s*=`).test(b2)) return null; // set from an expression: not a literal we can judge
549
+ else adds.push(field);
550
+ }
551
+ let t2 = text;
552
+ if (adds.length) {
553
+ // the new field names must be in scope where the update is written: a module that exports them and is already imported
554
+ let ok = false; let imported = text;
555
+ for (const mod of ['Web.Cookie', 'Web.Scotty.Cookie']) {
556
+ const info = importInfo(text, mod); if (!info) continue;
557
+ if (info.hiding) return null;
558
+ const allFields = info.items && info.items.some((x) => /^SetCookie\(\.\.\)$/.test(x.replace(/\s+/g, '')));
559
+ const r = allFields ? { text: imported, prefix: info.qualified ? `${info.alias || mod}.` : '' } : ensureImport(imported, mod, adds); if (!r) return null;
560
+ if (q && r.prefix && r.prefix !== q) continue;
561
+ imported = r.text; ok = true; break;
562
+ }
563
+ if (!ok) return null;
564
+ t2 = imported;
565
+ const closeAt = b2.lastIndexOf('}');
566
+ const head = b2.slice(0, closeAt).replace(/\s+$/, '');
567
+ b2 = `${head}, ${adds.map((a) => `${q}${a} = True`).join(', ')} ${b2.slice(closeAt)}`;
568
+ }
569
+ const patched = splice(text, site.start, site.end, b2);
570
+ next = t2 === text ? patched : applyImportDelta(text, t2, patched);
571
+ } else if (site.kind === 'scotty-helper') {
572
+ if (!importInfo(text, 'Web.Scotty.Cookie') || /^module\s+Web\.Scotty\.Cookie\b/m.test(text) || /^setCookie\b/m.test(text)) return null;
573
+ const head = text.slice(site.start, site.end);
574
+ const m = /^((?:[A-Z][\w']*\.)?)(setSimpleCookie|makeSimpleCookie)\s+/.exec(head); if (!m) return null;
575
+ const nameEnd = atomEnd(text, site.start + m[0].length); const valEnd = nameEnd < 0 ? -1 : atomEnd(text, nameEnd);
576
+ if (valEnd < 0) return null;
577
+ const tail = text.slice(valEnd, text.indexOf('\n', valEnd) < 0 ? text.length : text.indexOf('\n', valEnd)).trim();
578
+ const lineStart = text.lastIndexOf('\n', site.start) + 1;
579
+ // The helper is written out as the record update it is (scotty's `makeSimpleCookie n v = defaultSetCookie { setCookieName = encodeUtf8 n,
580
+ // setCookieValue = encodeUtf8 v }`, read from the scotty source): the wrapped form `(makeSimpleCookie n v) { .. }` is a shape the rule cannot
581
+ // see through, and a rescan that cannot confirm the cookie is secured is not a verification.
582
+ const nameArg = text.slice(site.start + m[0].length, nameEnd).trim(); const valArg = text.slice(nameEnd, valEnd).trim();
583
+ const needed = ['defaultSetCookie', 'setCookieName', 'setCookieValue', 'setCookieHttpOnly', 'setCookieSecure', ...(m[2] === 'setSimpleCookie' ? ['setCookie'] : [])];
584
+ const imp = ensureImport(text, 'Web.Scotty.Cookie', needed); if (!imp) return null;
585
+ if (/(?<![\w'.])AgenticTE\./.test(text)) return null;
586
+ const px = m[1] || imp.prefix;
587
+ const rec = `${px}defaultSetCookie { ${px}setCookieName = AgenticTE.encodeUtf8 ${nameArg}, ${px}setCookieValue = AgenticTE.encodeUtf8 ${valArg}, ${px}setCookieHttpOnly = True, ${px}setCookieSecure = True }`;
588
+ let repl;
589
+ if (m[2] === 'setSimpleCookie') {
590
+ if (tail !== '' || text.slice(lineStart, site.start).trim() !== '') return null; // only a whole statement: `setSimpleCookie name value`
591
+ repl = `${px}setCookie (${rec})`;
592
+ } else repl = `(${rec})`;
593
+ const patched = splice(text, site.start, valEnd, repl);
594
+ const withImports = applyImportDelta(text, imp.text, patched);
595
+ const ls = withImports.split('\n'); let lastImp = -1; ls.forEach((l, i) => { if (/^import\s/.test(l)) lastImp = i; });
596
+ ls.splice(lastImp + 1, 0, 'import qualified Data.Text.Encoding as AgenticTE');
597
+ next = ls.join('\n');
598
+ how = m[2];
599
+ }
600
+ if (!next || next === text) return null;
601
+ const sitesAfter = cookieSitesOf(next);
602
+ const ordinal = before.indexOf(site);
603
+ return {
604
+ ruleId: 'hs-cookie-attributes', label: 'FULL', text: next,
605
+ explanation: `Set ${missing.join(' and ')} on the cookie (${how} form). SameSite is not changed.`,
606
+ consequences,
607
+ behavior: { kind: 'cookie', form: how, add: missing },
608
+ expectEffective: { kind: 'cookie', ordinal, siteCount: before.length, expectSites: sitesAfter.length, httpOnly: true, secure: true, editAt },
609
+ };
610
+ }
611
+
612
+ // ── predictable randomness (CWE-338) ──────────────────────────────────────────
613
+ // Attempted ONLY where the generator is the entire value of a one-line action whose declared type is `IO <fixed-width integer>`:
614
+ // newCsrfToken :: IO Word64 -> newCsrfToken = foldl' (\acc b -> acc * 256 + fromIntegral b) 0 <$> getEntropy 8
615
+ // newCsrfToken = randomIO
616
+ // and ONLY when the file already imports System.Entropy (the dependency is therefore already declared). `randomRIO (lo, hi)`, a range, a generator
617
+ // 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
+ const FIXED_WIDTH = { Word8: 1, Word16: 2, Word32: 4, Word64: 8, Word: 8, Int8: 1, Int16: 2, Int32: 4, Int64: 8, Int: 8 };
619
+ function fixRandom(text, f) {
620
+ const lb = lineBounds(text, f.line);
621
+ const m = /^([a-z_][\w']*)\s*=\s*(?:System\.Random\.)?randomIO\s*$/.exec(lb.text);
622
+ if (!m) return null;
623
+ const name = m[1];
624
+ const sig = new RegExp(`^${name}\\s*::\\s*IO\\s+(${Object.keys(FIXED_WIDTH).join('|')})\\s*$`, 'm').exec(text);
625
+ if (!sig) return null;
626
+ if ((text.match(new RegExp(`^${name}\\s*=`, 'gm')) || []).length !== 1) return null;
627
+ const bytes = FIXED_WIDTH[sig[1]];
628
+ const ent = importInfo(text, 'System.Entropy'); if (!ent || ent.hiding) return null;
629
+ const e1 = ensureImport(text, 'System.Entropy', ['getEntropy']); if (!e1) return null;
630
+ if (/(?<![\w'.])AgenticBS\./.test(text)) return null;
631
+ let t2 = e1.text;
632
+ const lines = t2.split('\n'); let at = -1; lines.forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
633
+ lines.splice(at + 1, 0, 'import qualified Data.ByteString as AgenticBS');
634
+ t2 = lines.join('\n');
635
+ const body = `${name} = AgenticBS.foldl' (\\acc b -> acc * 256 + fromIntegral b) 0 <$> ${e1.prefix}getEntropy ${bytes}`;
636
+ const replaced = t2.replace(new RegExp(`^${name}\\s*=\\s*(?:System\\.Random\\.)?randomIO\\s*$`, 'm'), body);
637
+ if (replaced === t2) return null;
638
+ // the System.Random import loses `randomIO` when nothing else in the file uses it (an unused import is an error under -Werror)
639
+ let out = replaced;
640
+ const stillUsed = new RegExp(`(?<![\\w'])randomIO(?![\\w'])`).test(out.split('\n').filter((l) => !/^import\s/.test(l)).join('\n'));
641
+ const ri = importInfo(out, 'System.Random');
642
+ if (!stillUsed && ri && !ri.qualified && ri.items && ri.items.length === 1 && ri.items[0] === 'randomIO') out = out.replace(ri.line + '\n', '');
643
+ 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
+ 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.`,
647
+ 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
+ behavior: { kind: 'random', fn: name, type: sig[1], bytes },
649
+ expectEffective: { kind: 'random', fn: name, bytes },
650
+ };
651
+ }
652
+
653
+ // ── request body size (CWE-770) ───────────────────────────────────────────────
654
+ // `strictRequestBody req` reads the whole body into memory. The mitigation replaces the call with a bounded reader defined in the same file
655
+ // (getRequestBodyChunk until the end, or until a fixed byte limit is passed, in which case it throws). The limit is NOT derived from the service:
656
+ // it is a declared default (1 MiB), written as a named constant the operator must review. Only the wai `strictRequestBody <Request variable>` shape.
657
+ const BODY_LIMIT_DEFAULT = 1048576;
658
+ const BODY_HELPER = `-- Upper bound, in bytes, for a request body read by boundedRequestBody. This is a default chosen by the fixer, not a measured need: set it for this service.
659
+ requestBodyLimitBytes :: Int
660
+ requestBodyLimitBytes = ${BODY_LIMIT_DEFAULT}
661
+
662
+ -- Reads a request body chunk by chunk and throws once more than requestBodyLimitBytes have arrived, instead of buffering without limit.
663
+ boundedRequestBody req = go 0 []
664
+ where
665
+ go total acc = do
666
+ chunk <- AgentcW.getRequestBodyChunk req
667
+ if AgentcBS.null chunk
668
+ then pure (AgentcLBS.fromChunks (reverse acc))
669
+ else do
670
+ let total' = total + AgentcBS.length chunk
671
+ if total' > requestBodyLimitBytes
672
+ then AgentcE.throwIO (userError "request body exceeds requestBodyLimitBytes")
673
+ else go total' (chunk : acc)
674
+ `;
675
+ export { BODY_HELPER };
676
+ function fixBody(text, f) {
677
+ // The rule reports one finding per function, so fixing only the reported call would leave the next call of the same function as "the" finding. Every
678
+ // `strictRequestBody <variable>` call in the file is rewritten, and the plan says how many.
679
+ const code = stripComments(text);
680
+ const hits = [...code.matchAll(/(?<![\w'])strictRequestBody\s+([a-z_][\w']*)(?![\w'])/g)].map((h) => { const q = qualifierBefore(code, h.index); return { index: h.index - q.length, end: h.index + h[0].length, variable: h[1] }; });
681
+ const lineAt = (off) => text.slice(0, off).split('\n').length;
682
+ if (!hits.some((h) => lineAt(h.index) === f.line)) return null;
683
+ if (!importInfo(text, 'Network.Wai') || !importInfo(text, 'Data.ByteString.Lazy')) return null; // wai and bytestring are then declared by this component
684
+ if (hasPragma(text, 'NoImplicitPrelude') || /^import\s+(?:qualified\s+)?Prelude\s*(?:\(|hiding\b|as\b)/m.test(text)) return null; // the helper uses reverse, pure and userError from the Prelude
685
+ if (importInfo(text, 'Network.Wai').hiding) return null;
686
+ const hasHelper = text.includes(BODY_HELPER);
687
+ if (!hasHelper && /(?<![\w'.])(?:boundedRequestBody|requestBodyLimitBytes)(?![\w'])/.test(code)) return null; // a name of ours is already taken
688
+ let next = text;
689
+ for (const h of hits.slice().reverse()) next = splice(next, h.index, h.end, `boundedRequestBody ${h.variable}`);
690
+ if (!hasHelper) {
691
+ const lines = next.split('\n'); let at = -1; lines.forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
692
+ if (at < 0) return null;
693
+ lines.splice(at + 1, 0, 'import qualified Network.Wai as AgentcW', 'import qualified Data.ByteString as AgentcBS', 'import qualified Data.ByteString.Lazy as AgentcLBS', 'import qualified Control.Exception as AgentcE');
694
+ next = lines.join('\n').replace(/\n*$/, '\n') + '\n' + BODY_HELPER;
695
+ }
696
+ const n = hits.length;
697
+ return {
698
+ ruleId: 'hs-request-body-bound', label: 'MITIGATION', text: next,
699
+ explanation: `Replaced ${n} strictRequestBody call${n === 1 ? '' : 's'} with boundedRequestBody, which stops reading and throws once the body passes requestBodyLimitBytes (${BODY_LIMIT_DEFAULT} bytes, a declared default, not a measured need).`,
700
+ consequences: [`A request body larger than ${BODY_LIMIT_DEFAULT} bytes now raises an IOException in the ${n} rewritten call${n === 1 ? '' : 's'} (a 500 from the default handler, not a 413). Legitimate large uploads are rejected until requestBodyLimitBytes is raised.`, 'The limit applies only to the calls rewritten; every other body read keeps its old behavior.'],
701
+ behavior: { kind: 'body-limit', limit: BODY_LIMIT_DEFAULT, replaced: n },
702
+ expectEffective: { kind: 'body', replaced: n },
703
+ };
704
+ }
705
+
354
706
  const FIXERS = [
707
+ { id: 'hs-tls-validate', applies: (f) => f.cwe === 'CWE-295' || f.rule === 'hs-tls-validation-disabled', run: fixTls, reportedLineOnly: true },
708
+ { id: 'hs-cookie-attributes', applies: (f) => f.cwe === 'CWE-1004' || f.rule === 'hs-insecure-cookie', run: fixCookie, reportedLineOnly: true },
709
+ { id: 'hs-random-os-entropy', applies: (f) => f.cwe === 'CWE-338' || f.rule === 'hs-predictable-random', run: fixRandom, reportedLineOnly: true },
710
+ { id: 'hs-request-body-bound', applies: (f) => f.rule === 'hs-unbounded-request-body' || (f.cwe === 'CWE-770' && /request body/i.test(String(f.vuln || ''))), run: fixBody, reportedLineOnly: true },
355
711
  { id: 'hs-sql-parameterize', applies: (f) => f.cwe === 'CWE-89', run: fixSql },
356
712
  { id: 'hs-process-argv', applies: (f) => f.cwe === 'CWE-78', run: fixProcess },
357
713
  { id: 'hs-html-escape', applies: (f) => f.cwe === 'CWE-79', run: fixHtml },
@@ -390,33 +746,58 @@ export function planHaskellFix(finding, files) {
390
746
  const before = files[finding.file];
391
747
  if (typeof before !== 'string') return { ok: false, reason: `file ${finding.file} is not in the supplied tree` };
392
748
  if (!/\.l?hs$/i.test(finding.file)) return { ok: false, reason: 'not a Haskell source file' };
393
- if (/\{-#\s*LANGUAGE[^#]*\bCPP\b|^#\s*(?:if|define|include)\b/m.test(before)) return { ok: false, reason: 'the file uses CPP: a rewrite could change a branch that is not visible here', proposal: 'model-assisted' };
749
+ if (/\{-#\s*LANGUAGE[^#]*\bCPP\b|^#\s*(?:if|define|include)\b/m.test(before)) {
750
+ const cls = CLASS_REASONS[finding.cwe]; const marker = 'The decision needed:';
751
+ const classDecision = cls && cls.includes(marker) ? cls.slice(cls.indexOf(marker) + marker.length).trim() : null;
752
+ const decision = `${classDecision ? `${classDecision} Also, ` : ''}which build configuration (platform, compiler, flags) the patch is for, because a rewrite of a CPP file could change a branch that is not visible here.`;
753
+ return { ok: false, tier: TIERS.guidance, reason: `the file uses CPP: a rewrite could change a branch that is not visible here. The decision needed: ${decision}`, decision, proposal: 'model-assisted' };
754
+ }
394
755
  for (const fx of FIXERS) {
395
756
  let applies = false; try { applies = fx.applies(finding); } catch { /* ignore */ }
396
757
  if (!applies) continue;
397
758
  // The finding is reported where the tainted value ENTERS the code that reaches the sink: for a flow through a function of this
398
759
  // file that is the call, while the dangerous API call is inside the callee. Try the reported line first, then the bodies of
399
760
  // the same-file functions called on it.
400
- for (const line of [finding.line, ...calleeLines(before, finding.line)]) {
761
+ // An attribute, a generator or a limit is fixed where the finding says it is, never in the body of a function called on that line (that would change a
762
+ // helper for all of its callers: the scotty cookie helpers, for instance, are the library's own definitions).
763
+ for (const line of fx.reportedLineOnly ? [finding.line] : [finding.line, ...calleeLines(before, finding.line)]) {
401
764
  let r = null; try { r = fx.run(before, { ...finding, line }); } catch { r = null; }
402
- if (r && r.text && r.text !== before) return { ok: true, ruleId: r.ruleId, label: r.label, file: finding.file, before, after: r.text, explanation: r.explanation, behavior: r.behavior, ...(line !== finding.line ? { fixedAtLine: line } : {}) };
765
+ if (r && r.text && r.text !== before) return { ok: true, tier: TIERS.edit, ruleId: r.ruleId, label: r.label, file: finding.file, before, after: r.text, explanation: r.explanation, consequences: r.consequences || DEFAULT_CONSEQUENCES[r.ruleId] || [], behavior: r.behavior, expectEffective: r.expectEffective || null, ...(line !== finding.line ? { fixedAtLine: line } : {}) };
403
766
  }
404
767
  }
405
- return { ok: false, reason: CLASS_REASONS[finding.cwe] || 'no deterministic fix is supported for this exact shape', proposal: 'model-assisted' };
768
+ const reason = CLASS_REASONS[finding.cwe] || 'no deterministic fix is supported for this exact shape. The decision needed: what correct handling of this value is for this code (validate it, encode it for the sink it reaches, or stop it reaching the sink); the scanner has no verified rewrite for this shape.';
769
+ const marker = 'The decision needed:';
770
+ return { ok: false, tier: TIERS.guidance, reason, decision: reason.includes(marker) ? reason.slice(reason.indexOf(marker) + marker.length).trim() : null, proposal: 'model-assisted' };
406
771
  }
407
772
 
773
+ export const TIERS = Object.freeze({ edit: 'full-source-edit', guidance: 'guidance-only', blocked: 'blocked' });
774
+ // What each rewrite changes for the running program, stated so a patch never silently alters behavior (the fixers added with the
775
+ // remediation work carry their own, which also name the build requirements).
776
+ const DEFAULT_CONSEQUENCES = Object.freeze({
777
+ 'hs-sql-parameterize': ['Interpolated values are now bound parameters. A splice that is not a value (a table or column name, an operator, a keyword) cannot be a parameter and needs an allow-list instead.'],
778
+ 'hs-process-argv': ['No shell parses the command any more: pipes, redirection, globbing, variable expansion and quoting in the old string no longer apply, and the program is found by name on PATH.'],
779
+ 'hs-html-escape': ['Markup the value deliberately contained is now shown as text.'],
780
+ 'hs-hash-sha256': ['Every digest this code produces changes. Anything that stored or compares the old digests (a stored hash, a cache key, a signature) must be migrated.'],
781
+ 'hs-log-redact': ['The redacted value no longer appears in the log; the line loses that detail.'],
782
+ 'hs-path-takefilename': ['A name that carried subdirectories now selects a file directly inside the fixed directory; "." and ".." survive takeFileName, so directory operations are not rewritten.'],
783
+ });
784
+
408
785
  // 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.
786
+ // Each reason ends with "The decision needed: ..." naming what the operator has to decide; planHaskellFix returns that part as `decision`.
409
787
  const CLASS_REASONS = Object.freeze({
410
- 'CWE-918': 'server-side request forgery is fixed by an allow-list of hosts or a resolved-address policy, which is a decision about this service, not a rewrite of this line',
411
- 'CWE-88': 'argument injection is fixed by validating the value against what the program accepts; inserting "--" only helps programs that honour it, so no patch is made',
412
- 'CWE-916': 'password hashing needs a memory-hard or iterated KDF (Argon2id, scrypt, bcrypt, PBKDF2) from a library the project may not depend on, and existing stored hashes must be migrated',
413
- 'CWE-338': 'a predictable generator is replaced by the operating system CSPRNG (Crypto.Random), whose result type differs from System.Random: the surrounding code must change with it',
414
- 'CWE-1004': 'cookie attributes live in different places for each framework (a record field, a header string, a helper); the rewrite is not provably the same program',
415
- 'CWE-306': 'a missing authentication check needs a policy (which credential, which rejection); the scanner reports the handler and does not invent the guard',
416
- 'CWE-770': 'a resource limit is a number only the operator can choose (how many bytes, how many requests)',
417
- 'CWE-295': 'enabling certificate validation can break connections the project depends on; the trust store to use is a deployment decision',
418
- 'CWE-798': 'a hard-coded secret must be rotated and moved to a secret store; rewriting the line would leave the leaked value valid',
419
- 'CWE-611': 'XML entity handling depends on the parser configuration in use',
788
+ '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).',
789
+ '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.',
790
+ '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 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).',
791
+ '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.',
792
+ '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.',
793
+ '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).',
794
+ '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). The one recognised shape is wai strictRequestBody of a Request variable in a file importing Network.Wai and Data.ByteString.Lazy, which gets a bounded reader with a declared default; this is not that shape. The decision needed: the limit for this input, and what happens past it (reject with 413, truncate, spool to disk).',
795
+ '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.',
796
+ '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.',
797
+ '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.',
798
+ '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.',
799
+ '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 is rewritten. The decision needed: render the value through the project\'s escaping HTML library, or send it as text/plain.',
800
+ '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).',
420
801
  });
421
802
 
422
803
  // ── gates ────────────────────────────────────────────────────────────────────
@@ -440,21 +821,105 @@ export function syntaxGate(file, before, after) {
440
821
  return { ok: na <= nb, errorsBefore: nb, errorsAfter: na, detail: na <= nb ? 'no new syntax errors' : `${na - nb} new syntax error(s)` };
441
822
  }
442
823
 
443
- /** Compile check is OPT-IN. `opts.compile` must be true AND a ghc must exist; otherwise the result says it did not run. */
444
- export function compileGate(files, opts = {}) {
445
- if (opts.compile !== true) return { ran: false, ok: null, detail: 'not requested (default static scans never start GHC)' };
446
- const ghc = opts.ghc || 'ghc';
824
+ const _ghcProbe = new Map();
825
+ function probeGhc(ghc) {
826
+ if (_ghcProbe.has(ghc)) return _ghcProbe.get(ghc);
447
827
  const probe = spawnSync(ghc, ['--numeric-version'], { encoding: 'utf8', timeout: 20000 });
448
- if (probe.error || probe.status !== 0) return { ran: false, ok: null, detail: 'GHC is not available on this host; compile verification was not performed' };
828
+ const ok = !probe.error && probe.status === 0;
829
+ _ghcProbe.set(ghc, ok);
830
+ return ok;
831
+ }
832
+
833
+ /** The `-i` root a file implies from its `module A.B.C` header and its path (src/A/B/C.hs -> src). */
834
+ function sourceRootOf(file, text) {
835
+ const m = /^module\s+([A-Z][\w'.]*)/m.exec(String(text || ''));
836
+ if (!m || /\.\.|\.$/.test(m[1])) return null;
837
+ const tail = `${m[1].replace(/\./g, '/')}.hs`;
838
+ return file === tail ? '.' : (file.endsWith(`/${tail}`) ? file.slice(0, file.length - tail.length - 1) : null);
839
+ }
840
+
841
+ function ghcTypeCheck(ghc, tree, targets, timeoutMs) {
449
842
  const dir = mkdtempSync(join(tmpdir(), 'hs-compile-'));
450
843
  try {
451
- for (const [f, t] of Object.entries(files)) if (/\.l?hs$/i.test(f)) { mkdirSync(dirname(join(dir, f)), { recursive: true }); writeFileSync(join(dir, f), t); }
452
- const hs = Object.keys(files).filter((f) => /\.hs$/i.test(f));
453
- const r = spawnSync(ghc, ['-fno-code', '-isrc', '-i.', ...hs], { cwd: dir, encoding: 'utf8', timeout: opts.timeoutMs || 120000 });
454
- return { ran: true, ok: r.status === 0, detail: r.status === 0 ? 'type-checked' : (r.stderr || '').slice(0, 400) };
844
+ for (const [f, t] of Object.entries(tree)) if (/\.l?hs$/i.test(f) && typeof t === 'string') { mkdirSync(dirname(join(dir, f)), { recursive: true }); writeFileSync(join(dir, f), t); }
845
+ const roots = new Set(['src', '.']);
846
+ for (const f of targets) { const r = sourceRootOf(f, tree[f]); if (r) roots.add(r); }
847
+ const r = spawnSync(ghc, ['-fno-code', ...[...roots].map((x) => `-i${x}`), ...targets], { cwd: dir, encoding: 'utf8', timeout: timeoutMs });
848
+ return { status: r.status, stderr: String(r.stderr || '') };
455
849
  } finally { rmSync(dir, { recursive: true, force: true }); }
456
850
  }
457
851
 
852
+ /**
853
+ * Compile check is OPT-IN. `opts.compile` must be true AND a ghc must exist; otherwise the result says it did not run (never a pass).
854
+ * `opts.targets` limits the files handed to GHC (their local imports are found through the source roots); default is every .hs file.
855
+ * `opts.baseline` is the tree BEFORE the patch: a patched tree that fails to type-check is only a failure of the patch when the original
856
+ * type-checks on this host. When the original does not (a package this host lacks, a source directory GHC was not told about), or when the
857
+ * patch imports a module this host lacks, nothing can be concluded and the gate reports `ran:false`, not a pass and not a failure.
858
+ */
859
+ export function compileGate(files, opts = {}) {
860
+ if (opts.compile !== true) return { ran: false, ok: null, detail: 'not requested (default static scans never start GHC)' };
861
+ const ghc = opts.ghc || 'ghc';
862
+ if (!probeGhc(ghc)) return { ran: false, ok: null, detail: 'GHC is not available on this host; compile verification was not performed' };
863
+ const hsAll = Object.keys(files).filter((f) => /\.hs$/i.test(f));
864
+ const targets = (opts.targets && opts.targets.length ? opts.targets.filter((f) => /\.hs$/i.test(f) && f in files) : hsAll);
865
+ if (!targets.length) return { ran: false, ok: null, detail: 'no .hs file to type-check' };
866
+ const timeoutMs = opts.timeoutMs || 120000;
867
+ const r = ghcTypeCheck(ghc, files, targets, timeoutMs);
868
+ if (r.status === 0) return { ran: true, ok: true, detail: 'type-checked' };
869
+ if (opts.baseline) {
870
+ const missing = /Could not find module\s+[‘'`]([^’'`]+)[’'`]/.exec(r.stderr);
871
+ const b = ghcTypeCheck(ghc, opts.baseline, targets.filter((f) => f in opts.baseline), timeoutMs);
872
+ if (b.status !== 0) return { ran: false, ok: null, detail: `the original does not type-check on this host either (${(b.stderr.split('\n').find((l) => /error/i.test(l)) || b.stderr).trim().slice(0, 160)}), so the patch could not be judged by compiling` };
873
+ if (missing) return { ran: false, ok: null, detail: `the patch uses module ${missing[1]}, which is not installed on this host; the patch was not type-checked` };
874
+ }
875
+ return { ran: true, ok: false, detail: r.stderr.slice(0, 400) };
876
+ }
877
+
878
+ // ── the effective-change check ────────────────────────────────────────────────
879
+ // A rescan going quiet proves only that the finding stopped being REPORTED. This reads the value back from the patched file with readers that
880
+ // are independent of the editor (`tlsSettingsOf`, `cookieSitesOf`, the call counts) and requires the intended attribute to be the one that changed.
881
+ export function haskellEffectiveCheck(plan, patched) {
882
+ const want = plan.expectEffective;
883
+ if (!want) return { ok: true, ran: false, detail: 'not an attribute fix' };
884
+ const after = patched[plan.file];
885
+ if (typeof after !== 'string') return { ok: false, ran: true, detail: 'the patched file is not in the tree' };
886
+ const before = plan.before;
887
+ if (want.kind === 'tls') {
888
+ const b = tlsSettingsOf(before); const a = tlsSettingsOf(after);
889
+ if (a.length !== b.length) return { ok: false, ran: true, detail: `the number of TLS settings changed (${b.length} to ${a.length})` };
890
+ const at = want.ordinal;
891
+ if (!(b[at] && b[at].disableCertificateValidation === true)) return { ok: false, ran: true, detail: 'the planned site did not disable validation before the patch' };
892
+ if (a[at].disableCertificateValidation !== false) return { ok: false, ran: true, detail: `${TLS_FIELD} reads ${JSON.stringify(a[at].disableCertificateValidation)} after the patch, not False` };
893
+ for (let i = 0; i < b.length; i++) if (i !== at && (b[i].disableCertificateValidation !== a[i].disableCertificateValidation || JSON.stringify(b[i].others) !== JSON.stringify(a[i].others))) return { ok: false, ran: true, detail: `another TLS setting changed (site ${i})` };
894
+ if (JSON.stringify(b[at].others) !== JSON.stringify(a[at].others)) return { ok: false, ran: true, detail: 'the other settings of the edited site changed' };
895
+ return { ok: true, ran: true, detail: `${TLS_FIELD} reads False at the edited site and nothing else changed` };
896
+ }
897
+ if (want.kind === 'cookie') {
898
+ const b = cookieSitesOf(before); const a = cookieSitesOf(after);
899
+ if (a.length !== b.length) return { ok: false, ran: true, detail: `the number of cookie sites changed (${b.length} to ${a.length})` };
900
+ const at = want.ordinal;
901
+ if (!a[at] || !(a[at].httpOnly && a[at].secure)) return { ok: false, ran: true, detail: `the edited cookie still reads HttpOnly=${a[at] && a[at].httpOnly} Secure=${a[at] && a[at].secure} after the patch` };
902
+ for (let i = 0; i < b.length; i++) if (i !== at && (b[i].httpOnly !== a[i].httpOnly || b[i].secure !== a[i].secure || b[i].kind !== a[i].kind)) return { ok: false, ran: true, detail: `another cookie site changed (site ${i})` };
903
+ return { ok: true, ran: true, detail: 'the edited cookie reads HttpOnly and Secure after the patch and no other cookie changed' };
904
+ }
905
+ if (want.kind === 'random') {
906
+ const defLine = (t) => (stripComments(t).split('\n').find((l) => new RegExp(`^${want.fn}\\s*=`).test(l)) || '');
907
+ const d = defLine(after);
908
+ if (/randomIO|System\.Random/.test(d)) return { ok: false, ran: true, detail: 'the action still calls System.Random' };
909
+ 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)` };
910
+ return { ok: true, ran: true, detail: `${want.fn} reads ${want.bytes} byte(s) from getEntropy and no longer calls System.Random` };
911
+ }
912
+ if (want.kind === 'body') {
913
+ const count = (t, re) => (stripComments(t).match(re) || []).length;
914
+ const calls = (t) => count(t, /(?<![\w'])(?:[A-Z][\w']*\.)?strictRequestBody(?![\w'])/g);
915
+ if (calls(after) !== calls(before) - want.replaced) return { ok: false, ran: true, detail: `exactly ${want.replaced} strictRequestBody call(s) should have been replaced` };
916
+ if (!after.includes(BODY_HELPER)) return { ok: false, ran: true, detail: 'the bounded reader is not defined in the patched file' };
917
+ if (!new RegExp(`requestBodyLimitBytes\\s*=\\s*${BODY_LIMIT_DEFAULT}\\b`).test(after)) return { ok: false, ran: true, detail: 'the declared limit is not the one the patch states' };
918
+ return { ok: true, ran: true, detail: `${want.replaced} strictRequestBody call(s) were replaced by the bounded reader, whose limit is the declared constant` };
919
+ }
920
+ return { ok: false, ran: true, detail: `unknown effective check ${want.kind}` };
921
+ }
922
+
458
923
  /**
459
924
  * Validate (and optionally apply) a fix. Nothing is written unless every gate passes and `apply` is true.
460
925
  * @param {object} finding
@@ -463,23 +928,25 @@ export function compileGate(files, opts = {}) {
463
928
  export async function validateHaskellFix(finding, o) {
464
929
  const plan = planHaskellFix(finding, o.files);
465
930
  if (!plan.ok) return { status: 'unsupported', applied: false, ...plan };
466
- return runFixLifecycle({
931
+ const res = await runFixLifecycle({
467
932
  plan, files: o.files, finding, matchKey: key, rescan: o.rescan || defaultRescan,
468
- syntax: (p) => syntaxGate(p.file, p.before, p.after), behaviorCheck: o.behaviorCheck, witness: o.witness, requireWitness: o.requireWitness,
469
- compile: (patched) => compileGate(patched, o), requireCompile: o.requireCompile, apply: o.apply, root: o.root,
933
+ syntax: (p) => syntaxGate(p.file, p.before, p.after), behaviorCheck: o.behaviorCheck, effectiveCheck: haskellEffectiveCheck, witness: o.witness, requireWitness: o.requireWitness,
934
+ compile: (patched) => compileGate(patched, { ...o, baseline: o.files, targets: [plan.file] }), requireCompile: o.requireCompile, apply: o.apply, root: o.root,
470
935
  });
936
+ return { ...res, consequences: plan.consequences || [], tier: res.status === 'blocked' ? TIERS.blocked : plan.tier, explanation: plan.explanation };
471
937
  }
472
938
 
473
939
  /**
474
940
  * Verify a patch SUPPLIED BY SOMEONE ELSE (a user, a model) through the same gates as a deterministic fix: path, syntax,
475
- * the original finding gone, no new medium-or-higher finding. Nothing is written. `proposal` is the new text of `file`.
941
+ * the original finding gone, no new medium-or-higher finding, and a compile check when asked. Nothing is written. `proposal` is the new text of
942
+ * `file`. Reached from `agentic-security fix <id> --proposal <file>` (see `languageProposalPreview` in context.js).
476
943
  */
477
944
  export async function verifyHaskellProposal(finding, { files, file, proposal, rescan, compile, requireCompile }) {
478
945
  const before = files[file];
479
946
  const plan = { file, before: typeof before === 'string' ? before : '', after: proposal, label: 'WORKAROUND' };
480
947
  return runFixLifecycle({
481
948
  plan, files, finding, matchKey: key, rescan: rescan || defaultRescan, syntax: (p) => syntaxGate(p.file, p.before, p.after),
482
- compile: (patched) => compileGate(patched, { compile }), requireCompile, apply: false,
949
+ compile: (patched) => compileGate(patched, { compile, baseline: files, targets: [file] }), requireCompile, apply: false,
483
950
  });
484
951
  }
485
952