@clear-capabilities/agentic-security-scanner 0.195.9 → 0.195.11
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.
- package/CHANGELOG.md +26 -0
- package/dist/{851efefbfcfa2a9aa691.json → 13e6d1463f0aec440afa.json} +3 -3
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +13 -3
- package/src/language/haskell-fix.js +359 -7
- package/src/language/haskell-ir.js +135 -8
- package/src/language/haskell-models.js +9 -2
- package/src/language/haskell-security-rules.js +126 -8
- package/src/language/support-registry.js +2 -0
|
@@ -20,7 +20,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
20
20
|
import { parseHaskell } from './haskell-parser.js';
|
|
21
21
|
import { SENSITIVE } from './haskell-security-rules.js';
|
|
22
22
|
import { stripComments } from './haskell-comments.js';
|
|
23
|
-
import { compareVersions, parseVersion } from './haskell-manifests.js';
|
|
23
|
+
import { compareVersions, parseVersion, parseCabalFile, versionSatisfies } from './haskell-manifests.js';
|
|
24
24
|
import { runFixLifecycle, unifiedDiff, writeWithBackup, undoFix } from './fix-lifecycle.js';
|
|
25
25
|
|
|
26
26
|
export const HS_FIX_VERSION = 'haskell-fix/1';
|
|
@@ -703,7 +703,320 @@ function fixBody(text, f) {
|
|
|
703
703
|
};
|
|
704
704
|
}
|
|
705
705
|
|
|
706
|
+
// ── HTML from text (CWE-79): scotty's `html` of concatenated text ─────────────
|
|
707
|
+
// `html $ mconcat ["<h1>", v, "</h1>"]`: scotty's `html` sends lazy Text as text/html, so a value concatenated into the markup IS markup. The
|
|
708
|
+
// rewrite applies an escaper DEFINED IN THE FILE (the five characters that open markup or end a quoted attribute) to every non-literal part of
|
|
709
|
+
// the concatenation, or to the whole argument when it is one expression that contains no literal and no renderer. A MITIGATION: a value inside
|
|
710
|
+
// a <script>, a style, a URL or an unquoted attribute is not made safe by this escaping, and markup the value deliberately carried is now shown
|
|
711
|
+
// as text. Only in a file importing Web.Scotty (or Web.Scotty.Trans) and Data.Text.Lazy (so `text` is declared by the component). The rescan
|
|
712
|
+
// recognises the escaper by the entities it spells out, never by its name (`htmlEscapers` in haskell-guards.js).
|
|
713
|
+
const HTML_TEXT_HELPER = `-- Escapes the five characters that open HTML markup or end a quoted attribute, so a value is shown as text. Covers HTML text and quoted
|
|
714
|
+
-- attribute values; not a script, a style, a URL or an unquoted-attribute context.
|
|
715
|
+
agenticEscapeHtml :: AgenticTL.Text -> AgenticTL.Text
|
|
716
|
+
agenticEscapeHtml = AgenticTL.concatMap escapeChar
|
|
717
|
+
where
|
|
718
|
+
escapeChar '<' = AgenticTL.pack "<"
|
|
719
|
+
escapeChar '>' = AgenticTL.pack ">"
|
|
720
|
+
escapeChar '&' = AgenticTL.pack "&"
|
|
721
|
+
escapeChar '"' = AgenticTL.pack """
|
|
722
|
+
escapeChar '\\'' = AgenticTL.pack "'"
|
|
723
|
+
escapeChar c = AgenticTL.singleton c
|
|
724
|
+
`;
|
|
725
|
+
export { HTML_TEXT_HELPER };
|
|
726
|
+
const HTML_TEXT_ESCAPER = 'agenticEscapeHtml';
|
|
727
|
+
const RENDERER = /(?<![\w'])(?:render\w*|toHtml|preEscaped\w*|toMarkup|unsafeByteString|html\w*)(?![\w'])/;
|
|
728
|
+
function fixHtmlText(text, f) {
|
|
729
|
+
if (!/^import\s+(?:qualified\s+)?Web\.Scotty(?:\.Trans)?(?:\s|$|\()/m.test(text)) return null;
|
|
730
|
+
const tl = importInfo(text, 'Data.Text.Lazy'); if (!tl || tl.hiding) return null;
|
|
731
|
+
if (hasPragma(text, 'NoImplicitPrelude') || /^import\s+(?:qualified\s+)?Prelude\s*(?:\(|hiding\b|as\b)/m.test(text)) return null;
|
|
732
|
+
const code = stripComments(text);
|
|
733
|
+
const hasHelper = text.includes(HTML_TEXT_HELPER);
|
|
734
|
+
if (!hasHelper && (new RegExp(`(?<![\\w'.])${HTML_TEXT_ESCAPER}(?![\\w'])`).test(code) || /(?<![\w'.])AgenticTL\./.test(text))) return null; // a name of ours is already taken
|
|
735
|
+
const lb = lineBounds(text, f.line);
|
|
736
|
+
const call = findCall(lb.text, ['html']);
|
|
737
|
+
if (!call) return null;
|
|
738
|
+
let aStart = call.after; while (lb.text[aStart] === ' ') aStart++;
|
|
739
|
+
let aEnd; let dollar = false;
|
|
740
|
+
if (lb.text[aStart] === '$') { dollar = true; aStart++; while (lb.text[aStart] === ' ') aStart++; aEnd = lb.text.length; }
|
|
741
|
+
else aEnd = atomEnd(lb.text, aStart);
|
|
742
|
+
if (aEnd < 0 || aEnd <= aStart) return null;
|
|
743
|
+
const arg = lb.text.slice(aStart, aEnd).trim();
|
|
744
|
+
if (/\$/.test(arg) && !dollar) return null;
|
|
745
|
+
const inner = trimParens(arg);
|
|
746
|
+
if (!inner || STRING.test(inner)) return null; // a literal alone is not a flow
|
|
747
|
+
const wrap = (e) => (IDENT.test(e) ? `${HTML_TEXT_ESCAPER} ${e}` : `${HTML_TEXT_ESCAPER} (${e})`);
|
|
748
|
+
let rebuilt = null; const wrapped = [];
|
|
749
|
+
const cat = /^(?:mconcat|(?:[A-Z][\w']*\.)?concat)\s*\[([\s\S]*)\]$/.exec(inner);
|
|
750
|
+
if (cat) {
|
|
751
|
+
const parts = topLevelSplit(cat[1], ','); if (!parts) return null;
|
|
752
|
+
const out = [];
|
|
753
|
+
let literals = 0;
|
|
754
|
+
for (const raw of parts) {
|
|
755
|
+
const e = raw.trim();
|
|
756
|
+
if (!e) return null;
|
|
757
|
+
if (STRING.test(e)) { literals++; out.push(e); continue; }
|
|
758
|
+
if (/"/.test(e) || RENDERER.test(e)) return null; // a nested construction or a renderer: not judged
|
|
759
|
+
if (new RegExp(`^${HTML_TEXT_ESCAPER}\\s`).test(e)) { out.push(e); continue; } // already escaped
|
|
760
|
+
out.push(wrap(e)); wrapped.push(e);
|
|
761
|
+
}
|
|
762
|
+
if (!wrapped.length || !literals) return null; // nothing raw, or no markup to be concatenated into
|
|
763
|
+
rebuilt = `${inner.slice(0, inner.indexOf('['))}[${out.join(', ')}]`;
|
|
764
|
+
} else {
|
|
765
|
+
if (/"/.test(inner) || RENDERER.test(inner) || /\bagenticEscapeHtml\b/.test(inner)) return null;
|
|
766
|
+
rebuilt = wrap(inner); wrapped.push(inner);
|
|
767
|
+
}
|
|
768
|
+
const replaced = lb.text.slice(0, aStart) + (dollar ? rebuilt : `(${rebuilt})`) + lb.text.slice(aEnd);
|
|
769
|
+
let next = splice(text, lb.start, lb.end, replaced);
|
|
770
|
+
if (!hasHelper) {
|
|
771
|
+
const lines = next.split('\n'); let at = -1; lines.forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
|
|
772
|
+
if (at < 0) return null;
|
|
773
|
+
lines.splice(at + 1, 0, 'import qualified Data.Text.Lazy as AgenticTL');
|
|
774
|
+
next = lines.join('\n').replace(/\n*$/, '\n') + '\n' + HTML_TEXT_HELPER;
|
|
775
|
+
}
|
|
776
|
+
const n = wrapped.length;
|
|
777
|
+
return {
|
|
778
|
+
ruleId: 'hs-html-escape-text', label: 'MITIGATION', text: next,
|
|
779
|
+
explanation: `Applied ${HTML_TEXT_ESCAPER}, defined in this file, to ${n} value${n === 1 ? '' : 's'} (${wrapped.join(', ')}) before ${call.prefix}html sends the text as HTML: <, >, &, " and ' become entities, so the value is shown as text.`,
|
|
780
|
+
consequences: [
|
|
781
|
+
'Markup the value deliberately contained is now shown as text.',
|
|
782
|
+
'The escaping covers HTML text and quoted attribute values. A value placed inside a <script> or <style> element, in a URL or in an unquoted attribute is not made safe by it; those contexts need their own encoding.',
|
|
783
|
+
'Requires the `text` package to be declared by the component (this file already imports Data.Text.Lazy, so it is); the build is not checked against the cabal file.',
|
|
784
|
+
],
|
|
785
|
+
behavior: { kind: 'html-text', escaper: HTML_TEXT_ESCAPER, wrapped },
|
|
786
|
+
expectEffective: { kind: 'html-text', line: replaced, wrapped: n },
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ── password hashing (CWE-916): the migration shape ───────────────────────────
|
|
791
|
+
// A password hashed with a fast digest cannot be swapped for a KDF on its line: every stored hash was made by the old function, and a swap locks
|
|
792
|
+
// every user out. The rewrite that keeps them in is a MIGRATION, written out in the file: the old function is kept verbatim under `<name>Legacy`
|
|
793
|
+
// (verification of stored hashes only), `<name>` becomes PBKDF2-HMAC-SHA256 over the same salt and password with a tag in front of the hex
|
|
794
|
+
// (`pbkdf2-sha256$600000$...`, so a stored hash says how it was made), `<name>NeedsRehash` reads the tag, and `<name>Verify stored salt pw`
|
|
795
|
+
// checks either form. Every `stored == <name> salt pw` comparison in the file becomes `<name>Verify stored salt pw`; the caller re-hashes at
|
|
796
|
+
// the next successful login when `<name>NeedsRehash` says so (that write is the caller's, not the patch's). Recognised ONLY where the
|
|
797
|
+
// migration can be shown to keep behavior:
|
|
798
|
+
// - a one-line, top-level, pure definition `name a b = ...` of two plain parameters, one password-shaped and one salt-shaped, with a
|
|
799
|
+
// signature `T -> T -> T` for one T among String, Text and (strict) ByteString;
|
|
800
|
+
// - the file imports a module of cryptonite / crypton (`Crypto.Hash`, `Crypto.KDF.*`, ...), so `Crypto.KDF.PBKDF2` is in a declared package,
|
|
801
|
+
// and `Data.Text` (String and Text forms: the bytes are the UTF-8 encoding) or `Data.ByteString` (ByteString form);
|
|
802
|
+
// - no other file compares with the function (a comparison the patch cannot rewrite would lock those users out), and the four names are free.
|
|
803
|
+
// The iteration count (600000, the OWASP figure for PBKDF2-HMAC-SHA256) is a declared default the operator reviews, not a measured cost.
|
|
804
|
+
// The API is cryptonite's / crypton's, from the package documentation: `fastPBKDF2_SHA256 :: (ByteArrayAccess password, ByteArrayAccess salt,
|
|
805
|
+
// ByteArray out) => Parameters -> password -> salt -> out`, `data Parameters = Parameters { iterCounts :: Int, outputLength :: Int }`.
|
|
806
|
+
const KDF_ITERATIONS = 600000;
|
|
807
|
+
const KDF_TAG = `pbkdf2-sha256$${KDF_ITERATIONS}$`;
|
|
808
|
+
const KDF_LIB_IMPORT = /^import\s+(?:qualified\s+)?Crypto\.(?:Hash|KDF|MAC|Random|Cipher|Error|PubKey|Number)(?:\.|\s|$)/m;
|
|
809
|
+
const PASSWORD_PARAM = /pass(?:word|wd|phrase)?|pwd|^pw$|secret/i;
|
|
810
|
+
const SALT_PARAM = /salt|nonce|pepper/i;
|
|
811
|
+
const KDF_TYPES = Object.freeze({
|
|
812
|
+
String: { bytes: (v) => `AgenticTE.encodeUtf8 (AgenticT.pack ${v})`, hex: (h) => h, tag: `"${KDF_TAG}"`, prefix: 'AgenticList.isPrefixOf', needs: 'Data.Text' },
|
|
813
|
+
Text: { bytes: (v) => `AgenticTE.encodeUtf8 ${v}`, hex: (h) => `AgenticT.pack (${h})`, tag: `AgenticT.pack "${KDF_TAG}"`, prefix: 'AgenticT.isPrefixOf', needs: 'Data.Text' },
|
|
814
|
+
ByteString: { bytes: (v) => v, hex: (h) => `AgenticBC.pack (${h})`, tag: `AgenticBC.pack "${KDF_TAG}"`, prefix: 'AgenticBS.isPrefixOf', needs: 'Data.ByteString' },
|
|
815
|
+
});
|
|
816
|
+
const KDF_IMPORTS = ['import qualified Crypto.KDF.PBKDF2 as AgenticPBKDF2', 'import qualified Data.ByteString as AgenticBS', 'import qualified Data.ByteString.Char8 as AgenticBC', 'import qualified Data.Text as AgenticT', 'import qualified Data.Text.Encoding as AgenticTE', 'import qualified Data.List as AgenticList'];
|
|
817
|
+
const KDF_ALIASES = ['AgenticPBKDF2', 'AgenticBS', 'AgenticBC', 'AgenticT', 'AgenticTE', 'AgenticList'];
|
|
818
|
+
|
|
819
|
+
/** `stored == name a b` / `name a b == stored` on one line of code (atoms only); returns the rewritten line or null. */
|
|
820
|
+
function rewriteHashComparisons(lineText, name, verify) {
|
|
821
|
+
const atom = `(?:[A-Za-z_][\\w'.]*|"(?:[^"\\\\]|\\\\.)*"|\\([^()]*\\))`;
|
|
822
|
+
const call = `${name}\\s+(${atom})\\s+(${atom})`;
|
|
823
|
+
let n = 0;
|
|
824
|
+
let out = lineText.replace(new RegExp(`(?<![\\w'.])(${atom})\\s*==\\s*(?:${call}|\\(${call}\\))(?![\\w'])`, 'g'), (_, stored, a1, b1, a2, b2) => { n++; return `${verify} ${stored} ${a1 || a2} ${b1 || b2}`; });
|
|
825
|
+
out = out.replace(new RegExp(`(?<![\\w'.])(?:${call}|\\(${call}\\))\\s*==\\s*(${atom})(?![\\w'.])`, 'g'), (_, a1, b1, a2, b2, stored) => { n++; return `${verify} ${stored} ${a1 || a2} ${b1 || b2}`; });
|
|
826
|
+
return n ? { text: out, n } : null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function fixPasswordKdf(text, f) {
|
|
830
|
+
const lb = lineBounds(text, f.line);
|
|
831
|
+
const def = /^([a-z_][\w']*)\s+([a-z_][\w']*)\s+([a-z_][\w']*)\s*=\s*(\S.*)$/.exec(lb.text);
|
|
832
|
+
if (!def) return null;
|
|
833
|
+
const [, name, p1, p2, body] = def;
|
|
834
|
+
if (/(?<![\w'])(?:do|unsafePerformIO)(?![\w'])|<-/.test(body)) return null; // not a pure one-line definition
|
|
835
|
+
const pwIdx = [p1, p2].findIndex((p) => PASSWORD_PARAM.test(p) && !SALT_PARAM.test(p));
|
|
836
|
+
const saltIdx = [p1, p2].findIndex((p) => SALT_PARAM.test(p));
|
|
837
|
+
if (pwIdx < 0 || saltIdx < 0 || pwIdx === saltIdx) return null;
|
|
838
|
+
const sig = new RegExp(`^${name}\\s*::\\s*(\\S+)\\s*->\\s*(\\S+)\\s*->\\s*(\\S+)\\s*$`, 'm').exec(text);
|
|
839
|
+
if (!sig) return null;
|
|
840
|
+
const tyOf = (t) => { const m = /^(?:[A-Z][\w']*\.)?(String|Text|ByteString)$/.exec(t); return m ? m[1] : null; };
|
|
841
|
+
const T = tyOf(sig[1]);
|
|
842
|
+
if (!T || tyOf(sig[2]) !== T || tyOf(sig[3]) !== T) return null;
|
|
843
|
+
const ty = KDF_TYPES[T];
|
|
844
|
+
const code = stripComments(text);
|
|
845
|
+
if ((code.match(new RegExp(`^${name}\\s+[a-z_]`, 'gm')) || []).length !== 1) return null; // one clause, defined once
|
|
846
|
+
if (!KDF_LIB_IMPORT.test(text)) return null; // Crypto.KDF.PBKDF2 is not in a declared package
|
|
847
|
+
if (T === 'ByteString' && importInfo(text, 'Data.ByteString.Lazy') && !importInfo(text, 'Data.ByteString')) return null; // a lazy ByteString: not the strict type the KDF takes
|
|
848
|
+
if (!importInfo(text, ty.needs) && !(T !== 'ByteString' && importInfo(text, 'Data.Text.Encoding'))) return null;
|
|
849
|
+
const pb = importInfo(text, 'Crypto.KDF.PBKDF2'); if (pb && pb.hiding) return null;
|
|
850
|
+
if (hasPragma(text, 'NoImplicitPrelude') || /^import\s+(?:qualified\s+)?Prelude\s*(?:\(|hiding\b|as\b)/m.test(text)) return null;
|
|
851
|
+
const legacy = `${name}Legacy`, verify = `${name}Verify`, needs = `${name}NeedsRehash`, tagName = `${name}Tag`, hexName = `${name}Hex`;
|
|
852
|
+
for (const nm of [legacy, verify, needs, tagName, hexName]) if (new RegExp(`(?<![\\w'.])${nm}(?![\\w'])`).test(code)) return null;
|
|
853
|
+
if (new RegExp(`(?<![\\w'.])(?:${KDF_ALIASES.join('|')})\\.`).test(text)) return null;
|
|
854
|
+
// every comparison with the function, in this file, is rewritten; one in another file would be left comparing against the new form
|
|
855
|
+
const lines = text.split('\n');
|
|
856
|
+
const sigLine = lines.findIndex((l) => new RegExp(`^${name}\\s*::`).test(l));
|
|
857
|
+
let compared = 0;
|
|
858
|
+
const useRe = new RegExp(`(?<![\\w'.])${name}(?![\\w'])`);
|
|
859
|
+
for (let i = 0; i < lines.length; i++) {
|
|
860
|
+
if (i === f.line - 1 || i === sigLine || /^import\s/.test(lines[i])) continue;
|
|
861
|
+
const r = rewriteHashComparisons(lines[i], name, verify);
|
|
862
|
+
if (r) { lines[i] = r.text; compared += r.n; continue; }
|
|
863
|
+
if (useRe.test(stripComments(lines[i])) && /==/.test(lines[i])) return null; // a comparison of a shape the rewrite does not cover
|
|
864
|
+
}
|
|
865
|
+
for (const [other, t] of Object.entries(f._files || {})) {
|
|
866
|
+
if (other === f.file || typeof t !== 'string' || !/\.l?hs$/i.test(other)) continue;
|
|
867
|
+
if (t.split('\n').some((l) => !/^import\s/.test(l) && useRe.test(stripComments(l)) && /==/.test(l))) return null;
|
|
868
|
+
}
|
|
869
|
+
const pw = pwIdx === 0 ? p1 : p2, salt = saltIdx === 0 ? p1 : p2;
|
|
870
|
+
const hexOf = `${hexName} (AgenticPBKDF2.fastPBKDF2_SHA256 (AgenticPBKDF2.Parameters { AgenticPBKDF2.iterCounts = ${KDF_ITERATIONS}, AgenticPBKDF2.outputLength = 32 }) (${ty.bytes(pw)}) (${ty.bytes(salt)}))`;
|
|
871
|
+
// the original signature stays where it is, above the new definition; the kept function gets its own
|
|
872
|
+
const block = [
|
|
873
|
+
`-- PBKDF2-HMAC-SHA256, ${KDF_ITERATIONS} iterations (a declared default: review the cost for this service), tagged so a stored hash says how it was made.`,
|
|
874
|
+
`${name} ${p1} ${p2} = ${tagName} <> ${ty.hex(hexOf)}`,
|
|
875
|
+
'',
|
|
876
|
+
`${tagName} :: ${sig[3]}`,
|
|
877
|
+
`${tagName} = ${ty.tag}`,
|
|
878
|
+
'',
|
|
879
|
+
`${hexName} :: AgenticBS.ByteString -> String`,
|
|
880
|
+
`${hexName} = AgenticBS.foldr (\\w acc -> hexDigit (w \`div\` 16) : hexDigit (w \`mod\` 16) : acc) ""`,
|
|
881
|
+
` where hexDigit d = "0123456789abcdef" !! fromIntegral d`,
|
|
882
|
+
'',
|
|
883
|
+
`-- Kept, under a new name, for passwords stored before the migration: ${verify} checks a stored hash with it when ${needs} says so.`,
|
|
884
|
+
`${legacy} :: ${sig[1]} -> ${sig[2]} -> ${sig[3]}`,
|
|
885
|
+
`${legacy} ${p1} ${p2} = ${body}`,
|
|
886
|
+
'',
|
|
887
|
+
`-- True for a hash made by ${legacy}: verify it with ${verify}, then store ${name} of the password at the next successful login.`,
|
|
888
|
+
`${needs} :: ${sig[3]} -> Bool`,
|
|
889
|
+
`${needs} stored = not (${tagName} \`${ty.prefix}\` stored)`,
|
|
890
|
+
'',
|
|
891
|
+
`-- Checks a password against a stored hash of either form, in place of \`stored == ${name} ${salt} ${pw}\`.`,
|
|
892
|
+
`${verify} :: ${sig[3]} -> ${sig[1]} -> ${sig[2]} -> Bool`,
|
|
893
|
+
`${verify} stored ${p1} ${p2} = if ${needs} stored then stored == ${legacy} ${p1} ${p2} else stored == ${name} ${p1} ${p2}`,
|
|
894
|
+
];
|
|
895
|
+
lines.splice(f.line - 1, 1, ...block);
|
|
896
|
+
let at = -1; lines.forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
|
|
897
|
+
if (at < 0) return null;
|
|
898
|
+
lines.splice(at + 1, 0, ...KDF_IMPORTS);
|
|
899
|
+
let next = lines.join('\n');
|
|
900
|
+
// an export list that names the function exports the helpers too: a caller in another module needs the verify helper
|
|
901
|
+
const hdr = /^module\s+[\w.']+\s*\(/m.exec(next);
|
|
902
|
+
if (hdr) {
|
|
903
|
+
const open = hdr.index + hdr[0].length - 1;
|
|
904
|
+
const close = atomEnd(next, open);
|
|
905
|
+
if (close > 0 && /^\s*where/.test(next.slice(close))) {
|
|
906
|
+
const list = next.slice(open + 1, close - 1);
|
|
907
|
+
if (useRe.test(list)) next = next.slice(0, open + 1) + list.replace(useRe, `${name}, ${legacy}, ${verify}, ${needs}`) + next.slice(close - 1);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
ruleId: 'hs-password-kdf-migrate', label: 'MITIGATION', text: next,
|
|
912
|
+
explanation: `${name} now computes PBKDF2-HMAC-SHA256 (${KDF_ITERATIONS} iterations, 32 bytes, over the same salt and password) and tags the result; the old function is kept as ${legacy} for verification only, ${verify} checks a stored hash of either form, and ${needs} says which hashes to re-hash at the next successful login. ${compared} comparison${compared === 1 ? '' : 's'} in this file now call${compared === 1 ? 's' : ''} ${verify}.`,
|
|
913
|
+
consequences: [
|
|
914
|
+
`Requires cryptonite or crypton (Crypto.KDF.PBKDF2; this file already imports a module of that package), bytestring and text to be declared by the component; the build is not checked against the cabal file.`,
|
|
915
|
+
`A new password is stored in the tagged PBKDF2 form; a stored hash of the old form still verifies through ${legacy}, so no user is locked out, and it stays in the old form until the caller re-hashes it (store ${name} of the password when ${needs} is True after a successful login). ${compared === 0 ? `No comparison with ${name} was found in this file: a caller elsewhere that compares with == must call ${verify} instead.` : ''}`.trim(),
|
|
916
|
+
`Each hash now costs ${KDF_ITERATIONS} HMAC-SHA256 iterations (tenths of a second per attempt, by design); the count is a declared default, not a measured cost for this service. The comparison in ${verify} is the same == the original used, not a constant-time one.`,
|
|
917
|
+
],
|
|
918
|
+
behavior: { kind: 'kdf', fn: name, legacy, verify, needs, type: T, iterations: KDF_ITERATIONS, rewrittenComparisons: compared },
|
|
919
|
+
expectEffective: { kind: 'kdf', fn: name, legacy, verify, needs, type: T, rewrittenComparisons: compared },
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ── scotty request body limit (CWE-770) ───────────────────────────────────────
|
|
924
|
+
// scotty's `body` reads the whole request body. The bound scotty offers is application-wide: `setMaxRequestBodySize :: Kilobytes -> ScottyM ()`
|
|
925
|
+
// (scotty 0.12 and later; a request past it gets a 413 before any handler runs). The mitigation adds ONE call, as the first statement of the
|
|
926
|
+
// ScottyM block that builds the application this handler belongs to: the block that contains the read (an inline route), or the block in this
|
|
927
|
+
// file that names the handler function. The limit is a declared default (1 MiB) written as a named constant the operator must review. Refused:
|
|
928
|
+
// a block in another file (the rule can then only say "a limit exists somewhere"), a `do` that keeps a statement on its own line, a name of ours
|
|
929
|
+
// already taken, and a cabal file whose scotty bound excludes every version from 0.12 on.
|
|
930
|
+
const SCOTTY_LIMIT_KB = 1024;
|
|
931
|
+
const SCOTTY_LIMIT_CONST = 'agenticRequestBodyLimitKb';
|
|
932
|
+
const SCOTTY_LIMIT_ALIAS = 'AgenticScotty';
|
|
933
|
+
const SCOTTY_LIMIT_DEF = `-- Upper bound, in kilobytes, for any request body this application accepts: a request past it is answered 413 before a handler runs. This is a default chosen by the fixer, not a measured need: set it for this service.
|
|
934
|
+
${SCOTTY_LIMIT_CONST} :: Int
|
|
935
|
+
${SCOTTY_LIMIT_CONST} = ${SCOTTY_LIMIT_KB}
|
|
936
|
+
`;
|
|
937
|
+
export { SCOTTY_LIMIT_DEF, SCOTTY_LIMIT_KB };
|
|
938
|
+
const SCOTTY_VERSIONS_WITH_LIMIT = ['0.12', '0.12.1', '0.20', '0.21', '0.22', '0.30', '0.31'];
|
|
939
|
+
const TOP_DECL = /^([a-z_][\w']*)\b/;
|
|
940
|
+
const NOT_A_DEF = new Set(['import', 'module', 'data', 'type', 'newtype', 'class', 'instance', 'where', 'infix', 'infixl', 'infixr', 'foreign', 'default', 'deriving', 'pattern']);
|
|
941
|
+
// The ScottyM blocks of a file: `main = scotty 3000 $ do`, `app = scottyOpts opts $ do`, or `app :: ScottyM ()` with `app = do`. Each block:
|
|
942
|
+
// the top-level function that holds it, the line of its `do`, and the indentation of its first statement.
|
|
943
|
+
function scottyBlocksOf(code) {
|
|
944
|
+
const ls = code.split('\n');
|
|
945
|
+
const starts = []; ls.forEach((l, i) => { const m = TOP_DECL.exec(l); if (m && !NOT_A_DEF.has(m[1])) starts.push({ line: i, name: m[1] }); });
|
|
946
|
+
const fnAt = (i) => { let cur = null; for (const s of starts) { if (s.line <= i) cur = s; else break; } return cur ? cur.name : null; };
|
|
947
|
+
const sigs = new Set(); ls.forEach((l) => { const m = /^([a-z_][\w']*)\s*::\s*ScottyM\s*\(\)\s*$/.exec(l); if (m) sigs.add(m[1]); });
|
|
948
|
+
const out = [];
|
|
949
|
+
ls.forEach((l, i) => {
|
|
950
|
+
const inline = /(?:^|[\s$(])(?:[A-Z][\w']*\.)?scotty(?:Opts|T|OptsT|App|AppT)?\b[^\n]*\$\s*do\s*$/.test(l);
|
|
951
|
+
const named = (() => { const m = /^([a-z_][\w']*)\s*=\s*do\s*$/.exec(l); return !!(m && sigs.has(m[1])); })();
|
|
952
|
+
if (!inline && !named) return;
|
|
953
|
+
let j = i + 1; while (j < ls.length && ls[j].trim() === '') j++;
|
|
954
|
+
if (j >= ls.length) return;
|
|
955
|
+
const indent = /^(\s*)/.exec(ls[j])[1];
|
|
956
|
+
if (!indent.length || ls[j].trim().startsWith('where')) return;
|
|
957
|
+
out.push({ fn: fnAt(i), doLine: i, firstStatement: j, indent });
|
|
958
|
+
});
|
|
959
|
+
return { blocks: out, fnAt };
|
|
960
|
+
}
|
|
961
|
+
// The scotty bound every cabal file in the tree declares, judged against the versions that have the limit: null when no bound is declared.
|
|
962
|
+
function scottyBoundExcludesLimit(files) {
|
|
963
|
+
for (const [name, text] of Object.entries(files || {})) {
|
|
964
|
+
if (!/\.cabal$/i.test(name) || typeof text !== 'string') continue;
|
|
965
|
+
let parsed = null; try { parsed = parseCabalFile(text); } catch { parsed = null; }
|
|
966
|
+
for (const c of (parsed && parsed.components) || []) for (const d of c.dependencies || []) {
|
|
967
|
+
if (!d || d.name !== 'scotty' || !d.range) continue;
|
|
968
|
+
let admits = false;
|
|
969
|
+
for (const v of SCOTTY_VERSIONS_WITH_LIMIT) { try { if (versionSatisfies({ ok: true, ast: d.range }, v)) { admits = true; break; } } catch { admits = true; break; } }
|
|
970
|
+
if (!admits) return { file: name, rangeText: d.rangeText || '' };
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
function fixScottyBody(text, f) {
|
|
976
|
+
if (!/Web\.Scotty(?:\.Trans)?\.body\b/.test(String(f.description || f.detail || ''))) return null;
|
|
977
|
+
const trans = !importInfo(text, 'Web.Scotty') && importInfo(text, 'Web.Scotty.Trans');
|
|
978
|
+
if (!importInfo(text, 'Web.Scotty') && !trans) return null;
|
|
979
|
+
const code = stripComments(text);
|
|
980
|
+
if (new RegExp(`(?<![\\w'.])(?:${SCOTTY_LIMIT_CONST}|setMaxRequestBodySize)(?![\\w'])`).test(code) || new RegExp(`\\b${SCOTTY_LIMIT_ALIAS}\\b`).test(code)) return null;
|
|
981
|
+
const { blocks, fnAt } = scottyBlocksOf(code);
|
|
982
|
+
if (!blocks.length) return null;
|
|
983
|
+
const holder = fnAt(f.line - 1);
|
|
984
|
+
let block = blocks.find((b) => b.fn && b.fn === holder) || null;
|
|
985
|
+
if (!block && holder) {
|
|
986
|
+
const ls = code.split('\n');
|
|
987
|
+
const starts = []; ls.forEach((l, i) => { const m = TOP_DECL.exec(l); if (m && !NOT_A_DEF.has(m[1])) starts.push(i); });
|
|
988
|
+
const spanOf = (b) => { const s = b.doLine; let e = ls.length; for (const i of starts) if (i > s) { e = i; break; } return ls.slice(s, e).join('\n'); };
|
|
989
|
+
const naming = blocks.filter((b) => new RegExp(`(?<![\\w'.])${holder}(?![\\w'])`).test(spanOf(b)));
|
|
990
|
+
if (naming.length === 1) block = naming[0];
|
|
991
|
+
}
|
|
992
|
+
if (!block) return null;
|
|
993
|
+
const excluded = scottyBoundExcludesLimit(f._files);
|
|
994
|
+
if (excluded) return null;
|
|
995
|
+
const lines = text.split('\n');
|
|
996
|
+
const statement = `${block.indent}${SCOTTY_LIMIT_ALIAS}.setMaxRequestBodySize ${SCOTTY_LIMIT_CONST}`;
|
|
997
|
+
lines.splice(block.firstStatement, 0, statement);
|
|
998
|
+
// the last import of the CODE: an `import` inside a block comment (a commented-out alternative main) is not one
|
|
999
|
+
let at = -1; stripComments(lines.join('\n')).split('\n').forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
|
|
1000
|
+
if (at < 0) return null;
|
|
1001
|
+
lines.splice(at + 1, 0, `import qualified ${trans ? 'Web.Scotty.Trans' : 'Web.Scotty'} as ${SCOTTY_LIMIT_ALIAS}`);
|
|
1002
|
+
const next = lines.join('\n').replace(/\n*$/, '\n') + '\n' + SCOTTY_LIMIT_DEF;
|
|
1003
|
+
const insertedLine = block.firstStatement + 2; // 1-based, after the import line added above it
|
|
1004
|
+
const bound = (() => { for (const [name, t] of Object.entries(f._files || {})) if (/\.cabal$/i.test(name) && typeof t === 'string' && /\bscotty\b/.test(t)) return name; return null; })();
|
|
1005
|
+
return {
|
|
1006
|
+
ruleId: 'hs-scotty-body-limit', label: 'MITIGATION', text: next,
|
|
1007
|
+
explanation: `Added setMaxRequestBodySize ${SCOTTY_LIMIT_CONST} (${SCOTTY_LIMIT_KB} KB, a declared default, not a measured need) as the first statement of the ScottyM block in \`${block.fn || 'the application'}\`, which ${block.fn === holder ? 'contains this body read' : `registers \`${holder}\``}.`,
|
|
1008
|
+
consequences: [
|
|
1009
|
+
`Every route of the application built in \`${block.fn || 'this block'}\` now answers a request whose body exceeds ${SCOTTY_LIMIT_KB} KB with a 413 before the handler runs; legitimate large uploads are rejected until ${SCOTTY_LIMIT_CONST} is raised.`,
|
|
1010
|
+
`Requires scotty 0.12 or later (setMaxRequestBodySize); ${bound ? `the bound declared in ${bound} admits such a version, and the build is not checked against it` : 'no cabal file in the tree declares a scotty bound, so the version is not checked'}.`,
|
|
1011
|
+
],
|
|
1012
|
+
behavior: { kind: 'scotty-body-limit', fn: block.fn, kb: SCOTTY_LIMIT_KB, line: insertedLine },
|
|
1013
|
+
expectEffective: { kind: 'scotty-body-limit', line: insertedLine, statement },
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
706
1017
|
const FIXERS = [
|
|
1018
|
+
{ 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 },
|
|
707
1020
|
{ id: 'hs-tls-validate', applies: (f) => f.cwe === 'CWE-295' || f.rule === 'hs-tls-validation-disabled', run: fixTls, reportedLineOnly: true },
|
|
708
1021
|
{ id: 'hs-cookie-attributes', applies: (f) => f.cwe === 'CWE-1004' || f.rule === 'hs-insecure-cookie', run: fixCookie, reportedLineOnly: true },
|
|
709
1022
|
{ id: 'hs-random-os-entropy', applies: (f) => f.cwe === 'CWE-338' || f.rule === 'hs-predictable-random', run: fixRandom, reportedLineOnly: true },
|
|
@@ -711,6 +1024,7 @@ const FIXERS = [
|
|
|
711
1024
|
{ id: 'hs-sql-parameterize', applies: (f) => f.cwe === 'CWE-89', run: fixSql },
|
|
712
1025
|
{ id: 'hs-process-argv', applies: (f) => f.cwe === 'CWE-78', run: fixProcess },
|
|
713
1026
|
{ id: 'hs-html-escape', applies: (f) => f.cwe === 'CWE-79', run: fixHtml },
|
|
1027
|
+
{ id: 'hs-html-escape-text', applies: (f) => f.cwe === 'CWE-79', run: fixHtmlText, reportedLineOnly: true },
|
|
714
1028
|
{ id: 'hs-hash-sha256', applies: (f) => f.cwe === 'CWE-328' || f.rule === 'hs-weak-hash', run: fixWeakHash },
|
|
715
1029
|
{ id: 'hs-log-redact', applies: (f) => f.cwe === 'CWE-532' || f.rule === 'hs-sensitive-logging', run: fixLogging },
|
|
716
1030
|
{ id: 'hs-path-takefilename', applies: (f) => f.cwe === 'CWE-22', run: fixPath },
|
|
@@ -761,7 +1075,7 @@ export function planHaskellFix(finding, files) {
|
|
|
761
1075
|
// 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
1076
|
// helper for all of its callers: the scotty cookie helpers, for instance, are the library's own definitions).
|
|
763
1077
|
for (const line of fx.reportedLineOnly ? [finding.line] : [finding.line, ...calleeLines(before, finding.line)]) {
|
|
764
|
-
let r = null; try { r = fx.run(before, { ...finding, line }); } catch { r = null; }
|
|
1078
|
+
let r = null; try { r = fx.run(before, { ...finding, line, _files: files }); } catch { r = null; }
|
|
765
1079
|
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 } : {}) };
|
|
766
1080
|
}
|
|
767
1081
|
}
|
|
@@ -787,16 +1101,16 @@ const DEFAULT_CONSEQUENCES = Object.freeze({
|
|
|
787
1101
|
const CLASS_REASONS = Object.freeze({
|
|
788
1102
|
'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
1103
|
'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).',
|
|
1104
|
+
'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).',
|
|
791
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.',
|
|
792
1106
|
'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
1107
|
'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).
|
|
1108
|
+
'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).',
|
|
795
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.',
|
|
796
1110
|
'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
1111
|
'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
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.',
|
|
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
|
|
1113
|
+
'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.',
|
|
800
1114
|
'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).',
|
|
801
1115
|
});
|
|
802
1116
|
|
|
@@ -810,8 +1124,12 @@ export async function defaultRescan(files) {
|
|
|
810
1124
|
try {
|
|
811
1125
|
for (const [f, t] of Object.entries(files)) { mkdirSync(dirname(join(dir, f)), { recursive: true }); writeFileSync(join(dir, f), t); }
|
|
812
1126
|
const r = await runScan(dir, { deep: true });
|
|
813
|
-
// a flow the proof gate discharged (a dominating guard, a sanitizer) is reported but is not still a finding
|
|
814
|
-
|
|
1127
|
+
// a flow the proof gate discharged (a dominating guard, a sanitizer) is reported but is not still a finding, and neither is a finding the
|
|
1128
|
+
// engine LOWERED to information for a reason it states (`originalSeverity`: a recognised guard, an operator-controlled origin; the legacy
|
|
1129
|
+
// verifier of a password-hash migration): the same `actionable` rule the benches score by. A finding that is `info` in its own right (an MD5
|
|
1130
|
+
// whose use is not visible) is still counted, so a fix of it can still be shown to have removed it.
|
|
1131
|
+
const lowered = (f) => f.severity === 'info' && (f.originalSeverity || (f.evidence && f.evidence.legacyVerifier === true));
|
|
1132
|
+
return (r.scan.findings || []).filter((f) => !(f.proof && /^proven-/.test(f.proof.verdict)) && !lowered(f));
|
|
815
1133
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
816
1134
|
}
|
|
817
1135
|
|
|
@@ -909,6 +1227,17 @@ export function haskellEffectiveCheck(plan, patched) {
|
|
|
909
1227
|
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
1228
|
return { ok: true, ran: true, detail: `${want.fn} reads ${want.bytes} byte(s) from getEntropy and no longer calls System.Random` };
|
|
911
1229
|
}
|
|
1230
|
+
if (want.kind === 'scotty-body-limit') {
|
|
1231
|
+
const count = (t, re) => (stripComments(t).match(re) || []).length;
|
|
1232
|
+
const limits = (t) => count(t, /(?<![\w'])(?:[A-Z][\w']*\.)?setMaxRequestBodySize(?![\w'])/g);
|
|
1233
|
+
const bodies = (t) => count(t, /(?<![\w'.])body(?![\w'])/g);
|
|
1234
|
+
if (limits(after) !== limits(before) + 1) return { ok: false, ran: true, detail: 'exactly one setMaxRequestBodySize call should have been added' };
|
|
1235
|
+
const ls = stripComments(after).split('\n');
|
|
1236
|
+
if (ls[want.line - 1] !== want.statement) return { ok: false, ran: true, detail: `the limit is not the statement at line ${want.line} as planned` };
|
|
1237
|
+
if (bodies(after) !== bodies(before)) return { ok: false, ran: true, detail: 'the body reads changed; the patch should only add the limit' };
|
|
1238
|
+
if (!new RegExp(`^${SCOTTY_LIMIT_CONST}\\s*=\\s*${SCOTTY_LIMIT_KB}\\b`, 'm').test(after)) return { ok: false, ran: true, detail: 'the declared limit is not the one the patch states' };
|
|
1239
|
+
return { ok: true, ran: true, detail: `setMaxRequestBodySize is the first statement of the application block, with the declared constant (${SCOTTY_LIMIT_KB} KB), and no body read changed` };
|
|
1240
|
+
}
|
|
912
1241
|
if (want.kind === 'body') {
|
|
913
1242
|
const count = (t, re) => (stripComments(t).match(re) || []).length;
|
|
914
1243
|
const calls = (t) => count(t, /(?<![\w'])(?:[A-Z][\w']*\.)?strictRequestBody(?![\w'])/g);
|
|
@@ -917,6 +1246,29 @@ export function haskellEffectiveCheck(plan, patched) {
|
|
|
917
1246
|
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
1247
|
return { ok: true, ran: true, detail: `${want.replaced} strictRequestBody call(s) were replaced by the bounded reader, whose limit is the declared constant` };
|
|
919
1248
|
}
|
|
1249
|
+
if (want.kind === 'html-text') {
|
|
1250
|
+
const code = stripComments(after);
|
|
1251
|
+
if (!code.split('\n').includes(want.line)) return { ok: false, ran: true, detail: 'the rewritten html call is not in the patched file as planned' };
|
|
1252
|
+
if (!after.includes(HTML_TEXT_HELPER)) return { ok: false, ran: true, detail: 'the escaper is not defined in the patched file' };
|
|
1253
|
+
const uses = (t) => (stripComments(t).split('\n').filter((l) => !new RegExp(`^${HTML_TEXT_ESCAPER}\\s`).test(l)).join('\n').match(new RegExp(`(?<![\\w'.])${HTML_TEXT_ESCAPER}(?![\\w'])`, 'g')) || []).length;
|
|
1254
|
+
if (uses(after) - uses(before) !== want.wrapped) return { ok: false, ran: true, detail: `exactly ${want.wrapped} value(s) should have been wrapped; ${uses(after) - uses(before)} were` };
|
|
1255
|
+
return { ok: true, ran: true, detail: `${want.wrapped} value(s) pass through the file's escaper before the html call and the escaper spells out the entities` };
|
|
1256
|
+
}
|
|
1257
|
+
if (want.kind === 'kdf') {
|
|
1258
|
+
const code = stripComments(after); const ls = code.split('\n');
|
|
1259
|
+
const defs = (nm) => ls.filter((l) => new RegExp(`^${nm}\\s+[a-z_][\\w']*\\s+[a-z_][\\w']*\\s*=`).test(l));
|
|
1260
|
+
if (defs(want.legacy).length !== 1) return { ok: false, ran: true, detail: `${want.legacy} is not defined exactly once` };
|
|
1261
|
+
const oldBody = (stripComments(before).split('\n').find((l) => new RegExp(`^${want.fn}\\s+[a-z_][\\w']*\\s+[a-z_][\\w']*\\s*=`).test(l)) || '').replace(/^[^=]*=\s*/, '');
|
|
1262
|
+
if (!oldBody || defs(want.legacy)[0].replace(/^[^=]*=\s*/, '') !== oldBody) return { ok: false, ran: true, detail: `${want.legacy} is not the original definition kept verbatim` };
|
|
1263
|
+
const nd = defs(want.fn);
|
|
1264
|
+
if (nd.length !== 1 || !new RegExp(`fastPBKDF2_SHA256 .*iterCounts = ${KDF_ITERATIONS}\\b`).test(nd[0])) return { ok: false, ran: true, detail: `${want.fn} does not compute PBKDF2-HMAC-SHA256 with ${KDF_ITERATIONS} iterations` };
|
|
1265
|
+
const vd = ls.filter((l) => new RegExp(`^${want.verify}\\s+stored\\s`).test(l));
|
|
1266
|
+
if (vd.length !== 1 || !new RegExp(`${want.needs} stored then stored == ${want.legacy} .* else stored == ${want.fn} `).test(vd[0])) return { ok: false, ran: true, detail: `${want.verify} does not check both forms` };
|
|
1267
|
+
const comparisons = (t) => (stripComments(t).split('\n').filter((l) => !new RegExp(`^${want.verify}\\s`).test(l)).join('\n').match(new RegExp(`==\\s*\\(?${want.fn}\\s|${want.fn}\\s+\\S+\\s+\\S+\\)?\\s*==`, 'g')) || []).length;
|
|
1268
|
+
const verifyCalls = ls.filter((l) => !/^module\s/.test(l) && !new RegExp(`^${want.verify}\\s`).test(l) && new RegExp(`(?<![\\w'.])${want.verify}(?![\\w'])`).test(l)).length;
|
|
1269
|
+
if (comparisons(after) !== 0 || verifyCalls !== want.rewrittenComparisons) return { ok: false, ran: true, detail: `expected ${want.rewrittenComparisons} comparison(s) with ${want.fn} to call ${want.verify}; ${comparisons(after)} comparison(s) remain and ${verifyCalls} call it` };
|
|
1270
|
+
return { ok: true, ran: true, detail: `${want.legacy} keeps the original definition, ${want.fn} computes PBKDF2-HMAC-SHA256 with ${KDF_ITERATIONS} iterations, ${want.verify} checks both forms and the ${want.rewrittenComparisons} comparison(s) call it` };
|
|
1271
|
+
}
|
|
920
1272
|
return { ok: false, ran: true, detail: `unknown effective check ${want.kind}` };
|
|
921
1273
|
}
|
|
922
1274
|
|