@clear-capabilities/agentic-security-scanner 0.195.18 → 0.195.20
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 +22 -0
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{20ae520a6fc181040cfb.json → de59e9dc044605ecc699.json} +1 -1
- package/package.json +1 -1
- package/src/language/CLAUDE.md +1 -0
- package/src/language/haskell-fix.js +68 -18
- package/src/language/haskell-models.js +3 -1
- package/src/language/haskell-security-rules.js +13 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
60f87190cb5ccfffcc3fbe658229ca0a510ee96ea2877f0856dae461e7c98de0 agentic-security.mjs
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.195.
|
|
3
|
+
"version": "0.195.20",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.195.
|
|
3
|
+
"version": "0.195.20",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/language/CLAUDE.md
CHANGED
|
@@ -68,6 +68,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
|
|
|
68
68
|
- **A project definition of a modelled name keeps the registry's meaning (since 0.195.11).** `haskell-ir.js` `applyVar`/`lowerVarValue`: a resolution to a project `def` whose module-qualified name `isKnownApi` (the project IS the framework, or vendors it) is lowered as the LIBRARY call (`hs.status: 'external'`, `hs.modelledDefinition: qid`) and the definition is still referenced (`noteRef(..., 'modelled-definition')`, so it is never an uncalled entry point). Before this, IHP's own `IHP.Controller.Param.param` shadowed the catalog when IHP scanned itself, and a request parameter reaching `Process.system` through a helper was not reported (four lost true positives on the fresh set). A test stub named like a catalog module gets the same treatment, which is why the html-text remediation test keeps its scotty stub out of the scan. Pinned both ways in `test/haskell/haskell-taint-shapes.test.js`, with the three shapes below.
|
|
69
69
|
- **Graph and SCA items examined and NOT built (0.195.17; each premise of the plan turned out false or a contract change).** (1) CWE-78/88/94 sink nodes stay `unsupported` in `lineage/sink-registry.js`: `SINK_CATEGORIES` is FR-201's EGRESS taxonomy, process and code execution are in-process computation (82 of 194 sink entries, `lineage/DESIGN_REGISTRIES.md` section 9), and a new category is a signed cross-language contract change touching the schema, 50 references and the frontend parity fixture, not a Haskell improvement. (2) `findingRefs` is empty for every language, and a sink NODE has `location: null` by design (category-granular); a finding could attach to a FLOW through the sites of its path group, but the Haskell rule findings are produced after the graph is built, so it needs a post-pass over a signed artifact and flows that retain their sites. (3) `haskell-sca.js` `PACKAGE_MODULES` stays a nine-package table: a cabal `plan.json` carries units and dependencies, not module names; module lists live in the package database of the user's cabal store, outside the project, and this layer reads only explicitly supplied exact paths. Deriving them would need an opt-in package-database path. (4) `docs/capability-ledger.json` entries are all `proposed` by rule (`scripts/capability-ledger/check.mjs` fails any other status for an applicable entry): implemented status is tracked by the support registry, so CAP-009 and CAP-112 are not moved.
|
|
70
70
|
- **Fixers for the real true positives (0.195.16; the count in the older "Ten fixers" bullet is now fifteen: `hs-path-contain` and `hs-xml-no-entity-substitution` are new).** `hs-process-argv` reads a `Text` command (`system (cs t)` / `(T.unpack t)` of a `<>` concatenation, on the line or in a one-line `let` of the same function, which is removed; `concatParts` splits on `<>` when no `++` is present) and keeps the unpack per argument; `spawnCommand` is recognised; `CLASS_REASONS['CWE-78']` (new) names the decision when the program is a variable (IHP's editor from an environment variable stays refused: `code --goto` is not one program name). `hs-path-contain` (`fixPathContain`, `signatureAt`): a guard equation before the function's first equation, `| ".." \`elem\` splitDirectories p || isAbsolute p = pure Nothing`, only for exactly one String/FilePath parameter and a `Maybe` result (bare or under one monad); the shape is the one `haskell-guards.js` already classifies as a traversal check, so the rescan passes on semantics; effective kind `path-contain` (one added equation, the guard present). `hs-tls-validate` gains `fixOpenSslVerify`: `contextSetVerificationMode ctx VerifyNone` as a do-block statement becomes `contextSetDefaultVerifyPaths ctx` + `(VerifyPeer True True Nothing)` (HsOpenSSL 0.11.7.11), MITIGATION because the host name is not checked (`enableHostnameValidation` needs the connection object); effective kind `tls-openssl`. `hs-log-redact` no longer rewrites inside a string literal (the guide's captured example had shown `" "[REDACTED]"="`). `hs-xml-no-entity-substitution` (`fixXmlEntities`): `withSubstDTDEntities yes|True` to `no|False`, FULL, effective kind `hxt-entities`. `hs-pbkdf2-low-iterations` findings are routed to `hs-password-kdf-migrate` (the old count becomes the legacy verifier). Tests: `haskell-remediation-text-command.test.js`, `haskell-remediation-path-contain.test.js`, `haskell-remediation-0195-16.test.js`.
|
|
71
|
+
- **The password-hash migration over the SHA package (since 0.195.20; `test/haskell/haskell-remediation-kdf.test.js`, section 2b).** gitit's shape: the file hashes with `Data.Digest.Pure.SHA` and imports no cryptonite/crypton module, so no library KDF is in a declared package. `fixPasswordKdf` now has two backends: `crypton` (the library KDF, as before) and `sha`, which writes PBKDF2-HMAC-SHA256 for one 32-byte block (RFC 8018 section 5.2) out in the file as `<name>Pbkdf2` over the package's `hmacSha256` (documented at SHA 1.6.4.4 over LAZY bytes, with `bytestringDigest`). The bytes of a `String` come from utf8-string when a module of it is imported, else from `text` (lazy `encodeUtf8 . pack`); a `Text` from `text`; a strict `ByteString` from `fromStrict`; no declared package for the bytes is a refusal. The rest of the migration (tag, `Legacy`, `NeedsRehash`, `Verify`, the comparison rewrite, the export list) is shared. The construction was checked against the RFC 7914 section 11 vectors with the real package: the test compiles the GENERATED definition with only its count literal replaced by the vector's count, and skips loudly where `ghc-pkg latest SHA` fails (on the measuring host it ran through `GHC_PACKAGE_PATH` pointing at the cabal store). Cost: the package is pure Haskell; one hash at 600000 iterations measured 4.3 s of CPU at `-O2` on the maintainer's Apple Silicon host (2026-11-03), so the consequences say "seconds, not tenths" and that lowering the count weakens the hash. The rule side: `legacyVerifierOnly` counts a project function as a KDF call when it IS the construction, `writtenOutKdf` (an HMAC of a modelled module, `iterate`, and `Data.Bits.xor` among the calls of the function and its where-bound definitions; decided from the calls, never from a name; an iterated HMAC with no xor fold is not it, pinned), and `HS_RULE_APIS` registers the SHA package's HMACs and digest readers so a test stub of that module keeps the registry meaning. The effective check reads the written-out definition back at the planned count. `CLASS_REASONS` gained CWE-248, CWE-327 and CWE-329, so every class the Haskell rules report without a fixer names its decision (pinned over eleven classes in `haskell-remediation-classes.test.js`). Verified on the real gitit site (`State.hs:69`, three labelled sites carried on it): the labelled true positives with a verified patch are 20 of 27.
|
|
71
72
|
- **`hs-random-os-entropy` v2 (since 0.195.16; `test/haskell/haskell-remediation-entropy.test.js`).** `haskell-fix.js` `fixRandomLine`: three shapes from the real-code true positives, `replicateM n (randomRIO (c1, c2))` (n bytes mapped into the character range), `randomRIO (lo, hi)` with integer literals (8 bytes decoded big-endian, reduced into the range), `(take n . map f . randomRs (lo, hi)) <$> newStdGen` (n bytes reduced and mapped). The reduction is a modulus (bias stated in the consequences). `System.Entropy` is imported when missing, so the patch is a MITIGATION (the operator declares `entropy`) and FULL only when the file already imported it; the same relaxation applies to the older `name = randomIO` shape. The unused System.Random names are dropped from the import. Effective-check kind `random-line` (the edited line, located by content after the import removal may have moved it, reads `getEntropy n` and calls no System.Random name). Verified on the three real sites (scotty, gitit, hackage-server) with `entropy` installed for GHC 9.4.8 on the measuring host.
|
|
72
73
|
- **Rule precision from semantics (since 0.195.15; `test/haskell/haskell-rule-precision.test.js`).** `haskell-security-rules.js`: a partial list function (`NONEMPTY_TOTAL`) on a list that is a cons, a non-empty literal, or a concatenation with one of those (`nonEmptyList`) is total and not reported; the conditions a function tests include the first argument of `when`/`unless`/`guard` (`COND_COMBINATOR`), not only `if` nodes; a comparison of a list's LENGTH with a number (`case length xs of 3`, `length xs == 2`; `LENGTH_CALL`, `lengthVars` for the scratch variable the IR puts a case scrutinee in) tests the list. `hash x :: Digest MD5` / `hashlazy x :: Digest SHA1`: `haskell-syntax.js` keeps a `Digest <algo>` annotation as a `typed` node (`DIGEST_TYPE`), `haskell-ir.js` returns the inner call stamped `hs.digest`, and `hs-weak-hash` treats it like `hashWith MD5`. `hs-unsafe-io-boundary` is already lowered to `info` for every site by `engine.js` (`originalSeverity` kept), so no shape rule was added for it.
|
|
73
74
|
- **Record dot, labels, global cells, lambda-form functions, more syntax (since 0.195.14).** `haskell-syntax.js` reads `r.field` / `(e).field` / `(.field)` as a field selection (`sel` token, `RECORD_DOT_PRAGMA`; the IR lowers `field r` to a member read of a known record field) and `#field` as a label value (`LABELS_PRAGMA`; the IR lowers it to a literal, so IHP's `get #f r` / `set #f v r` are library calls carrying the record's and the value's taint, not a field selection: a stated limit); both only under the LANGUAGE pragma. `\cases` (`lamcases`), `mdo` (a `do` under `RECDO_PRAGMA`; `rec` blocks are not read), GADT `data T where` constructors (names, record fields, arity by arrows) and `pattern P ..` (a `patsyn` declaration, not a function named `pattern`) parse. `haskell-ir.js` `lambdaBodied` rewrites `f = \x -> ..`, `f = \case ..`, `f = \cases ..` into `f x = ..` at the top level and in `let`/`where` (a call then resolves to the function instead of applying a closure value). `cellBodyOf` recognises a module-level cell (`name = unsafePerformIO (newIORef ..)`, MVar/TVar/STRef alike) and `lowerCellOp` names it `$cell:<qid>` for the whole project (a read is that ident with `hs.globalCell`, a write an assignment to it); `dataflow/engine.js` `_cellSeeds` records a cell any function assigned tainted data to and `_unionAnnotationTaint` seeds it at the entry of every Haskell function, the fixed-point pre-pass re-running when the set grows (flow-insensitive; the root is not carried across, so the trust verdict of the reader is `unattributed`). `aliasBodyOf` accepts a partial application of a library function to names or literals (`remember = writeIORef lastName`), with the argument names checked for shadowing. `haskell-models.js`: typed-process `proc` (CWE-78 program, CWE-88 arguments), esqueleto `rawSql`/`rawExecute`, optparse-applicative `execParser`/`customExecParser` as a `cli` source, versions recorded in `HS_PACKAGES`. Pinned in `test/haskell/haskell-{record-dot,global-cell,syntax-extensions}.test.js`.
|
|
@@ -1031,7 +1031,16 @@ const KDF_TYPES = Object.freeze({
|
|
|
1031
1031
|
ByteString: { bytes: (v) => v, hex: (h) => `AgenticBC.pack (${h})`, tag: `AgenticBC.pack "${KDF_TAG}"`, prefix: 'AgenticBS.isPrefixOf', needs: 'Data.ByteString' },
|
|
1032
1032
|
});
|
|
1033
1033
|
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'];
|
|
1034
|
-
|
|
1034
|
+
// The same migration where the file hashes with the `SHA` package and imports no cryptonite/crypton module (gitit's shape): PBKDF2-HMAC-SHA256
|
|
1035
|
+
// for one 32-byte block (RFC 8018 section 5.2) is written out in the file over that package's HMAC, documented at SHA 1.6.4.4 as
|
|
1036
|
+
// `hmacSha256 :: ByteString -> ByteString -> Digest SHA256State` and `bytestringDigest :: Digest t -> ByteString`, both over LAZY bytes. The
|
|
1037
|
+
// construction is checked against the RFC 7914 section 11 vectors (the first 32 bytes of each) by `test/haskell/haskell-remediation-kdf.test.js`
|
|
1038
|
+
// where the package is installed. The bytes of a String come from utf8-string (`Data.ByteString.Lazy.UTF8.fromString`) when the file imports a
|
|
1039
|
+
// module of that package, else from `text`; a Text's from `text`; a strict ByteString's from `fromStrict`. The package is pure Haskell, so one
|
|
1040
|
+
// hash at the declared count costs seconds of CPU, not tenths; the consequences say so.
|
|
1041
|
+
const KDF_SHA_IMPORTS = ['import qualified Data.Digest.Pure.SHA as AgenticSHA', 'import qualified Data.ByteString.Lazy as AgenticBL', 'import qualified Data.Bits as AgenticBits', 'import qualified Data.List as AgenticList'];
|
|
1042
|
+
const KDF_ALIASES = ['AgenticPBKDF2', 'AgenticBS', 'AgenticBC', 'AgenticT', 'AgenticTE', 'AgenticList', 'AgenticSHA', 'AgenticBL', 'AgenticBits', 'AgenticUTF8', 'AgenticTL', 'AgenticTLE'];
|
|
1043
|
+
const TEXT_IMPORT = /^import\s+(?:qualified\s+)?Data\.Text(?:\.|\s|$)/m;
|
|
1035
1044
|
|
|
1036
1045
|
/** `stored == name a b` / `name a b == stored` on one line of code (atoms only); returns the rewritten line or null. */
|
|
1037
1046
|
function rewriteHashComparisons(lineText, name, verify) {
|
|
@@ -1057,16 +1066,32 @@ function fixPasswordKdf(text, f) {
|
|
|
1057
1066
|
const tyOf = (t) => { const m = /^(?:[A-Z][\w']*\.)?(String|Text|ByteString)$/.exec(t); return m ? m[1] : null; };
|
|
1058
1067
|
const T = tyOf(sig[1]);
|
|
1059
1068
|
if (!T || tyOf(sig[2]) !== T || tyOf(sig[3]) !== T) return null;
|
|
1060
|
-
|
|
1069
|
+
let ty = KDF_TYPES[T];
|
|
1061
1070
|
const code = stripComments(text);
|
|
1062
1071
|
if ((code.match(new RegExp(`^${name}\\s+[a-z_]`, 'gm')) || []).length !== 1) return null; // one clause, defined once
|
|
1063
|
-
|
|
1072
|
+
// the KDF comes from a declared package: cryptonite/crypton (Crypto.KDF.PBKDF2), or the SHA package's HMAC with the derivation written out
|
|
1073
|
+
const shaPkg = importInfo(text, 'Data.Digest.Pure.SHA');
|
|
1074
|
+
const backend = KDF_LIB_IMPORT.test(text) ? 'crypton' : (shaPkg && !shaPkg.hiding ? 'sha' : null);
|
|
1075
|
+
if (!backend) return null;
|
|
1064
1076
|
if (T === 'ByteString' && importInfo(text, 'Data.ByteString.Lazy') && !importInfo(text, 'Data.ByteString')) return null; // a lazy ByteString: not the strict type the KDF takes
|
|
1065
|
-
|
|
1066
|
-
|
|
1077
|
+
let typeImports = []; // the imports the bytes conversion of this type needs, on top of the backend's
|
|
1078
|
+
let bytesPackage = null; // the package that conversion requires the component to declare
|
|
1079
|
+
if (backend === 'crypton') {
|
|
1080
|
+
if (!importInfo(text, ty.needs) && !(T !== 'ByteString' && importInfo(text, 'Data.Text.Encoding'))) return null;
|
|
1081
|
+
const pb = importInfo(text, 'Crypto.KDF.PBKDF2'); if (pb && pb.hiding) return null;
|
|
1082
|
+
} else if (T === 'String') {
|
|
1083
|
+
if (importInfo(text, 'Data.ByteString.Lazy.UTF8') || importInfo(text, 'Data.ByteString.UTF8')) { ty = { ...ty, bytes: (v) => `AgenticUTF8.fromString ${v}` }; typeImports = ['import qualified Data.ByteString.Lazy.UTF8 as AgenticUTF8']; bytesPackage = 'utf8-string'; }
|
|
1084
|
+
else if (TEXT_IMPORT.test(text)) { ty = { ...ty, bytes: (v) => `AgenticTLE.encodeUtf8 (AgenticTL.pack ${v})` }; typeImports = ['import qualified Data.Text.Lazy as AgenticTL', 'import qualified Data.Text.Lazy.Encoding as AgenticTLE']; bytesPackage = 'text'; }
|
|
1085
|
+
else return null; // no declared package turns a String into UTF-8 bytes
|
|
1086
|
+
} else if (T === 'Text') {
|
|
1087
|
+
if (!TEXT_IMPORT.test(text)) return null;
|
|
1088
|
+
ty = { ...ty, bytes: (v) => `AgenticTLE.encodeUtf8 (AgenticTL.fromStrict ${v})` }; typeImports = ['import qualified Data.Text as AgenticT', 'import qualified Data.Text.Lazy as AgenticTL', 'import qualified Data.Text.Lazy.Encoding as AgenticTLE']; bytesPackage = 'text';
|
|
1089
|
+
} else {
|
|
1090
|
+
ty = { ...ty, bytes: (v) => `AgenticBL.fromStrict ${v}` }; typeImports = ['import qualified Data.ByteString as AgenticBS', 'import qualified Data.ByteString.Char8 as AgenticBC'];
|
|
1091
|
+
}
|
|
1067
1092
|
if (hasPragma(text, 'NoImplicitPrelude') || /^import\s+(?:qualified\s+)?Prelude\s*(?:\(|hiding\b|as\b)/m.test(text)) return null;
|
|
1068
|
-
const legacy = `${name}Legacy`, verify = `${name}Verify`, needs = `${name}NeedsRehash`, tagName = `${name}Tag`, hexName = `${name}Hex`;
|
|
1069
|
-
for (const nm of [legacy, verify, needs, tagName, hexName]) if (new RegExp(`(?<![\\w'.])${nm}(?![\\w'])`).test(code)) return null;
|
|
1093
|
+
const legacy = `${name}Legacy`, verify = `${name}Verify`, needs = `${name}NeedsRehash`, tagName = `${name}Tag`, hexName = `${name}Hex`, kdfName = `${name}Pbkdf2`;
|
|
1094
|
+
for (const nm of [legacy, verify, needs, tagName, hexName, ...(backend === 'sha' ? [kdfName] : [])]) if (new RegExp(`(?<![\\w'.])${nm}(?![\\w'])`).test(code)) return null;
|
|
1070
1095
|
if (new RegExp(`(?<![\\w'.])(?:${KDF_ALIASES.join('|')})\\.`).test(text)) return null;
|
|
1071
1096
|
// every comparison with the function, in this file, is rewritten; one in another file would be left comparing against the new form
|
|
1072
1097
|
const lines = text.split('\n');
|
|
@@ -1084,7 +1109,10 @@ function fixPasswordKdf(text, f) {
|
|
|
1084
1109
|
if (t.split('\n').some((l) => !/^import\s/.test(l) && useRe.test(stripComments(l)) && /==/.test(l))) return null;
|
|
1085
1110
|
}
|
|
1086
1111
|
const pw = pwIdx === 0 ? p1 : p2, salt = saltIdx === 0 ? p1 : p2;
|
|
1087
|
-
const hexOf =
|
|
1112
|
+
const hexOf = backend === 'crypton'
|
|
1113
|
+
? `${hexName} (AgenticPBKDF2.fastPBKDF2_SHA256 (AgenticPBKDF2.Parameters { AgenticPBKDF2.iterCounts = ${KDF_ITERATIONS}, AgenticPBKDF2.outputLength = 32 }) (${ty.bytes(pw)}) (${ty.bytes(salt)}))`
|
|
1114
|
+
: `${hexName} (${kdfName} (${ty.bytes(pw)}) (${ty.bytes(salt)}))`;
|
|
1115
|
+
const bytesModule = backend === 'crypton' ? 'AgenticBS' : 'AgenticBL';
|
|
1088
1116
|
// the original signature stays where it is, above the new definition; the kept function gets its own
|
|
1089
1117
|
const block = [
|
|
1090
1118
|
`-- 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.`,
|
|
@@ -1093,8 +1121,18 @@ function fixPasswordKdf(text, f) {
|
|
|
1093
1121
|
`${tagName} :: ${sig[3]}`,
|
|
1094
1122
|
`${tagName} = ${ty.tag}`,
|
|
1095
1123
|
'',
|
|
1096
|
-
|
|
1097
|
-
|
|
1124
|
+
...(backend === 'sha' ? [
|
|
1125
|
+
`-- PBKDF2-HMAC-SHA256 (RFC 8018 section 5.2) for one 32-byte block, over the SHA package's HMAC: U1 = HMAC(password, salt || 00 00 00 01), U(i) = HMAC(password, U(i-1)), and the key is the xor of U1 .. U(${KDF_ITERATIONS}).`,
|
|
1126
|
+
`${kdfName} :: AgenticBL.ByteString -> AgenticBL.ByteString -> AgenticBL.ByteString`,
|
|
1127
|
+
`${kdfName} password salt = AgenticList.foldl' xorBytes u1 (take (${KDF_ITERATIONS} - 1) (tail (iterate prf u1)))`,
|
|
1128
|
+
' where',
|
|
1129
|
+
' prf = AgenticSHA.bytestringDigest . AgenticSHA.hmacSha256 password',
|
|
1130
|
+
' u1 = prf (AgenticBL.append salt (AgenticBL.pack [0, 0, 0, 1]))',
|
|
1131
|
+
' xorBytes a b = AgenticBL.pack (AgenticBL.zipWith AgenticBits.xor a b)',
|
|
1132
|
+
'',
|
|
1133
|
+
] : []),
|
|
1134
|
+
`${hexName} :: ${bytesModule}.ByteString -> String`,
|
|
1135
|
+
`${hexName} = ${bytesModule}.foldr (\\w acc -> hexDigit (w \`div\` 16) : hexDigit (w \`mod\` 16) : acc) ""`,
|
|
1098
1136
|
` where hexDigit d = "0123456789abcdef" !! fromIntegral d`,
|
|
1099
1137
|
'',
|
|
1100
1138
|
`-- Kept, under a new name, for passwords stored before the migration: ${verify} checks a stored hash with it when ${needs} says so.`,
|
|
@@ -1112,7 +1150,7 @@ function fixPasswordKdf(text, f) {
|
|
|
1112
1150
|
lines.splice(f.line - 1, 1, ...block);
|
|
1113
1151
|
let at = -1; lines.forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
|
|
1114
1152
|
if (at < 0) return null;
|
|
1115
|
-
lines.splice(at + 1, 0, ...KDF_IMPORTS);
|
|
1153
|
+
lines.splice(at + 1, 0, ...(backend === 'crypton' ? KDF_IMPORTS : [...KDF_SHA_IMPORTS, ...typeImports]));
|
|
1116
1154
|
let next = lines.join('\n');
|
|
1117
1155
|
// an export list that names the function exports the helpers too: a caller in another module needs the verify helper
|
|
1118
1156
|
const hdr = /^module\s+[\w.']+\s*\(/m.exec(next);
|
|
@@ -1126,14 +1164,18 @@ function fixPasswordKdf(text, f) {
|
|
|
1126
1164
|
}
|
|
1127
1165
|
return {
|
|
1128
1166
|
ruleId: 'hs-password-kdf-migrate', label: 'MITIGATION', text: next,
|
|
1129
|
-
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}.`,
|
|
1167
|
+
explanation: `${name} now computes PBKDF2-HMAC-SHA256 (${KDF_ITERATIONS} iterations, 32 bytes, over the same salt and password${backend === 'sha' ? `, written out in ${kdfName} over the SHA package's hmacSha256` : ''}) 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}.`,
|
|
1130
1168
|
consequences: [
|
|
1131
|
-
|
|
1169
|
+
backend === 'crypton'
|
|
1170
|
+
? `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.`
|
|
1171
|
+
: `Requires the SHA package (Data.Digest.Pure.SHA; this file already imports it)${bytesPackage ? `, bytestring and ${bytesPackage}` : ' and bytestring'} to be declared by the component; the build is not checked against the cabal file. ${kdfName} is the RFC 8018 construction written out over that package's HMAC, not a library KDF: review it, or replace it with a KDF package once one is declared.`,
|
|
1132
1172
|
`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(),
|
|
1133
|
-
|
|
1173
|
+
backend === 'crypton'
|
|
1174
|
+
? `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.`
|
|
1175
|
+
: `Each hash now costs ${KDF_ITERATIONS} HMAC-SHA256 iterations. The SHA package is pure Haskell, so at this count one hash costs seconds of CPU per attempt, not tenths (a C-backed KDF package is faster by an order of magnitude); the count is a declared default, not a measured cost for this service, and lowering it weakens the hash. The comparison in ${verify} is the same == the original used, not a constant-time one.`,
|
|
1134
1176
|
],
|
|
1135
|
-
behavior: { kind: 'kdf', fn: name, legacy, verify, needs, type: T, iterations: KDF_ITERATIONS, rewrittenComparisons: compared },
|
|
1136
|
-
expectEffective: { kind: 'kdf', fn: name, legacy, verify, needs, type: T, rewrittenComparisons: compared },
|
|
1177
|
+
behavior: { kind: 'kdf', fn: name, legacy, verify, needs, type: T, iterations: KDF_ITERATIONS, rewrittenComparisons: compared, backend, ...(backend === 'sha' ? { kdf: kdfName } : {}) },
|
|
1178
|
+
expectEffective: { kind: 'kdf', fn: name, legacy, verify, needs, type: T, rewrittenComparisons: compared, backend, ...(backend === 'sha' ? { kdf: kdfName } : {}) },
|
|
1137
1179
|
};
|
|
1138
1180
|
}
|
|
1139
1181
|
|
|
@@ -1321,7 +1363,10 @@ export const CLASS_REASONS = Object.freeze({
|
|
|
1321
1363
|
'CWE-918': 'server-side request forgery has no line-level repair: a check on the URL string is bypassed by redirects, alternate encodings and DNS. The decision needed: which hosts or networks this service may call (an allow-list of exact hosts, or an address policy that rejects loopback, link-local and private ranges after name resolution and on every redirect).',
|
|
1322
1364
|
'CWE-78': 'a shell command string is replaced by a direct call of the program with an argument list. The recognised shape is callCommand/system/spawnCommand of a ++ or <> concatenation (String, or Text under cs/unpack, on the line or in a one-line let of the same function) whose first word is a string literal naming the program. The decision needed: when the program itself is a variable (an editor or tool chosen through an environment variable, say), how that value splits into a program and its own arguments, because a configured command such as "code --goto" is not one program name; and for an argument built with if/case or a function call, the exact argument list.',
|
|
1323
1365
|
'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.',
|
|
1324
|
-
'CWE-916': 'password hashing needs a password KDF (Argon2id, scrypt, bcrypt, or PBKDF2 with a high iteration count) from a library the project may not declare as a dependency, and every stored hash was produced by the old function: swapping it makes every existing password fail to verify and locks the users out. The one shape rewritten is a migration: a pure one-line `name salt password = ...` of one text type (String, Text or strict ByteString) in a file that already imports cryptonite or crypton
|
|
1366
|
+
'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 (with Data.Text or Data.ByteString), or the SHA package (with utf8-string or text for the bytes of a String), whose comparisons all sit in this file; it keeps the old function for verification, adds PBKDF2-HMAC-SHA256 (the library KDF, or the RFC 8018 construction written out over the SHA package\'s HMAC) 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).',
|
|
1367
|
+
'CWE-248': 'a partial function on caller-supplied input (an index with !!, head, fromJust, read) has no rewrite that keeps the function\'s type: the total form needs a value for the failure, and the code does not say which. The decision needed: what the function returns when the input is out of range or does not parse (an error response from the handler that read the input, an unchanged result, a default), and where that check belongs (at the read, so the handler answers the caller, or before the index).',
|
|
1368
|
+
'CWE-327': 'a broken cipher or ECB mode is replaced by an AEAD (AES-GCM or ChaCha20-Poly1305), which changes the stored form: everything encrypted so far must be re-encrypted or read through the old cipher once, and the nonce and authentication tag need a place beside the ciphertext. The decision needed: the AEAD and key size, the migration of the ciphertext already stored, and where the nonce and tag are kept.',
|
|
1369
|
+
'CWE-329': 'a static IV or nonce is replaced by a fresh random one per message, which then has to travel with the ciphertext: the decrypting side must read it from there, and the messages encrypted so far carry none. The decision needed: where the IV is stored or sent (in front of the ciphertext, a separate field) and how the decrypting code tells an old message from a new one.',
|
|
1325
1370
|
'CWE-338': 'a predictable generator is replaced by the operating system CSPRNG (System.Entropy or Crypto.Random), whose result type is bytes, not System.Random\'s: the surrounding code must change with it. Four shapes are rewritten (System.Entropy imported when missing, which makes the patch a MITIGATION until `entropy` is declared): a one-line action of type IO <fixed-width integer> equal to randomIO; `replicateM n (randomRIO (c1, c2))` over a character range; `randomRIO (lo, hi)` over integer literals; `(take n . map f . randomRs (lo, hi)) <$> newStdGen`. Any other shape (a generator threaded through other code, a non-literal range, a non-integer result) is not. The decision needed: the token\'s type and size (a 30-bit integer cannot be made unguessable by changing the generator) and the entropy library the project declares.',
|
|
1326
1371
|
'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.',
|
|
1327
1372
|
'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).',
|
|
@@ -1512,7 +1557,12 @@ export function haskellEffectiveCheck(plan, patched) {
|
|
|
1512
1557
|
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*/, '');
|
|
1513
1558
|
if (!oldBody || defs(want.legacy)[0].replace(/^[^=]*=\s*/, '') !== oldBody) return { ok: false, ran: true, detail: `${want.legacy} is not the original definition kept verbatim` };
|
|
1514
1559
|
const nd = defs(want.fn);
|
|
1515
|
-
|
|
1560
|
+
// the written-out construction (SHA package): the function calls it, and its definition reads back as the iterated HMAC at the planned count
|
|
1561
|
+
const writtenOut = want.backend === 'sha' && nd.length === 1 && new RegExp(`(?<![\\w'.])${want.kdf}(?![\\w'])`).test(nd[0])
|
|
1562
|
+
&& ls.some((l) => new RegExp(`^${want.kdf}\\s+password\\s+salt\\s*=\\s*AgenticList\\.foldl' xorBytes u1 \\(take \\(${KDF_ITERATIONS} - 1\\) \\(tail \\(iterate prf u1\\)\\)\\)\\s*$`).test(l))
|
|
1563
|
+
&& ls.some((l) => /^\s+prf = AgenticSHA\.bytestringDigest \. AgenticSHA\.hmacSha256 password\s*$/.test(l));
|
|
1564
|
+
const library = want.backend !== 'sha' && nd.length === 1 && new RegExp(`fastPBKDF2_SHA256 .*iterCounts = ${KDF_ITERATIONS}\\b`).test(nd[0]);
|
|
1565
|
+
if (!writtenOut && !library) return { ok: false, ran: true, detail: `${want.fn} does not compute PBKDF2-HMAC-SHA256 with ${KDF_ITERATIONS} iterations` };
|
|
1516
1566
|
const vd = ls.filter((l) => new RegExp(`^${want.verify}\\s+stored\\s`).test(l));
|
|
1517
1567
|
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` };
|
|
1518
1568
|
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;
|
|
@@ -406,7 +406,9 @@ export const HS_RULE_APIS = Object.freeze([
|
|
|
406
406
|
...E('Crypto.Hash', ['hash', 'hashWith', 'hashlazy']),
|
|
407
407
|
...E('Crypto.Hash.MD5', ['hash', 'hashlazy']), ...E('Crypto.Hash.SHA1', ['hash', 'hashlazy']),
|
|
408
408
|
...E('Crypto.Hash.SHA256', ['hash', 'hashlazy']), ...E('Crypto.Hash.SHA512', ['hash', 'hashlazy']),
|
|
409
|
-
...E('Data.Digest.Pure.MD5', ['md5']),
|
|
409
|
+
...E('Data.Digest.Pure.MD5', ['md5']),
|
|
410
|
+
// SHA 1.6.4.4: the digests, and the HMACs a written-out PBKDF2 is built on (the rule layer recognises that construction by its calls)
|
|
411
|
+
...E('Data.Digest.Pure.SHA', ['sha1', 'sha256', 'sha512', 'hmacSha224', 'hmacSha256', 'hmacSha384', 'hmacSha512', 'bytestringDigest', 'showDigest']),
|
|
410
412
|
...E('Crypto.Cipher.Types', ['makeIV', 'ecbEncrypt', 'ecbDecrypt', 'nullIV', 'cbcEncrypt', 'cbcDecrypt']),
|
|
411
413
|
...E('Crypto.Cipher.ChaChaPoly1305', ['nonce12', 'nonce8']),
|
|
412
414
|
...E('Crypto.KDF.PBKDF2', ['generate', 'fastPBKDF2_SHA1', 'fastPBKDF2_SHA256', 'fastPBKDF2_SHA512']),
|
|
@@ -68,6 +68,10 @@ const SET_COOKIE_HEADER = new Set(['Network.HTTP.Types.Header.hSetCookie', 'Netw
|
|
|
68
68
|
const REQUEST_LENGTH = new Set(['Network.Wai.requestBodyLength']);
|
|
69
69
|
const RELATIONAL = new Set(['<', '<=', '>', '>=']);
|
|
70
70
|
const PBKDF2_CALLS = new Set(['Crypto.KDF.PBKDF2.generate', 'Crypto.KDF.PBKDF2.fastPBKDF2_SHA1', 'Crypto.KDF.PBKDF2.fastPBKDF2_SHA256', 'Crypto.KDF.PBKDF2.fastPBKDF2_SHA512']);
|
|
71
|
+
// The HMACs a written-out PBKDF2 may be built on (SHA 1.6.4.4: `hmacSha256 :: ByteString -> ByteString -> Digest SHA256State`, lazy bytes;
|
|
72
|
+
// crypton/cryptonite `Crypto.MAC.HMAC.hmac`), and the names of Prelude's `iterate` the resolver records.
|
|
73
|
+
const HMAC_CALLS = new Set(['Data.Digest.Pure.SHA.hmacSha224', 'Data.Digest.Pure.SHA.hmacSha256', 'Data.Digest.Pure.SHA.hmacSha384', 'Data.Digest.Pure.SHA.hmacSha512', 'Crypto.MAC.HMAC.hmac']);
|
|
74
|
+
const ITERATE_CALLS = new Set(['iterate', 'Prelude.iterate', 'Data.List.iterate']);
|
|
71
75
|
const STRONG_KDF = /^(?:Crypto\.KDF\.BCrypt\.|Crypto\.Argon2\.|Crypto\.Scrypt\.|Crypto\.KDF\.Scrypt\.|Crypto\.PasswordStore\.|Crypto\.BCrypt\.)/;
|
|
72
76
|
const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'System.IO.Unsafe.unsafeInterleaveIO': 'unsafeInterleaveIO', 'System.IO.Unsafe.unsafeDupablePerformIO': 'unsafeDupablePerformIO', 'Unsafe.Coerce.unsafeCoerce': 'unsafeCoerce' };
|
|
73
77
|
|
|
@@ -396,8 +400,16 @@ function legacyVerifierOnly(fn, ir, memo) {
|
|
|
396
400
|
const allFns = Object.values(ir.perFile).flatMap((x) => x.functions || []);
|
|
397
401
|
const byQid = new Map(allFns.map((x) => [x.qid, x]));
|
|
398
402
|
const usesF = (e) => !!e.hs && ((e.kind === 'call' && e.hs.target === fn.qid) || e.hs.inlinedFrom === fn.qid);
|
|
403
|
+
// A project function that IS the PBKDF2 construction written out (RFC 8018 section 5.2) counts as a KDF call: an HMAC of a modelled module,
|
|
404
|
+
// repeated with `iterate`, folded with `xor`, in the function or its local (where-bound) definitions. Decided from the calls the IR recorded,
|
|
405
|
+
// never from a name; a function that only calls an HMAC once, or iterates without the xor fold, is not a derivation and keeps the full finding.
|
|
406
|
+
const writtenOutKdf = (g) => {
|
|
407
|
+
const own = allFns.filter((h) => h.file === g.file && (h.qid === g.qid || (typeof h.name === 'string' && h.name.startsWith(`${g.name}.`))));
|
|
408
|
+
const calls = own.flatMap((h) => (h.calls || []).map((c) => c.callee));
|
|
409
|
+
return calls.some((c) => HMAC_CALLS.has(c)) && calls.some((c) => ITERATE_CALLS.has(c)) && calls.some((c) => c === 'Data.Bits.xor');
|
|
410
|
+
};
|
|
399
411
|
const kdfCall = (c) => typeof c === 'string' && (STRONG_KDF.test(c) || PBKDF2_CALLS.has(c));
|
|
400
|
-
const hasKdf = (g, depth) => (g.calls || []).some((c) => kdfCall(c.callee) || (
|
|
412
|
+
const hasKdf = (g, depth) => writtenOutKdf(g) || (g.calls || []).some((c) => kdfCall(c.callee) || (c.hs && c.hs.target && c.hs.target !== g.qid && byQid.has(c.hs.target) && (writtenOutKdf(byQid.get(c.hs.target)) || (depth < 1 && hasKdf(byQid.get(c.hs.target), depth + 1)))));
|
|
401
413
|
let uses = 0; let compared = 0; let besideKdf = false;
|
|
402
414
|
const visit = (e, parent, g, depth) => {
|
|
403
415
|
if (!e || typeof e !== 'object' || depth > 60) return;
|