@clear-capabilities/agentic-security-scanner 0.195.8 → 0.195.10
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/{06198b6f3fac9b1cb25f.json → 312ef9976a988b322ade.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/language/CLAUDE.md +14 -3
- package/src/language/haskell-fix.js +251 -5
- package/src/language/haskell-guards.js +131 -5
- package/src/language/haskell-ir.js +9 -3
- package/src/language/haskell-models.js +5 -0
- package/src/language/haskell-security-rules.js +173 -13
- package/src/language/haskell-trust.js +19 -7
- package/src/language/haskell-web.js +9 -2
- package/src/language/nix-script-taint.js +13 -0
- package/src/language/nix-secrets.js +14 -0
- package/src/language/nixos-hardening.js +20 -3
- package/src/language/support-registry.js +2 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4104b02904216922e410b459fcde235a0bf73ec5b3515a3bc07acec62312fe65 agentic-security.mjs
|
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.10",
|
|
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",
|
|
@@ -93,8 +93,8 @@
|
|
|
93
93
|
"test:lineage": "node --test test/lineage/analysis-completion.test.js test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
|
|
94
94
|
"test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
|
|
95
95
|
"test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
|
|
96
|
-
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
|
97
|
-
"test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
|
|
96
|
+
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
|
97
|
+
"test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nix-development-shapes.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
|
|
98
98
|
"test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/frozen-splits.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-parity-haskell.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
|
|
99
99
|
"test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
|
|
100
100
|
"test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
|
package/src/language/CLAUDE.md
CHANGED
|
@@ -66,14 +66,14 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
|
|
|
66
66
|
- **Guards are recognised by what they do.** `haskell-guards.js`: a project function is an HTML escaper when it (or its local helpers) spells out the entities for `<`, `>` and `&` (`htmlEscapers`; never by name), and it neutralises a CWE-79 flow only when EVERY use of the value in the sink argument is under such a call (`allUsesEscaped`); a host allow-list may be a named constant and may sit in an `&&`; `sh -c SCRIPT arg0 arg1` is shell injection only when the SCRIPT is built from the value, and tainted positional parameters are data when the constant script expands them only inside double quotes after `--` (`quotedPositionals`, which decodes the Haskell string escapes the IR keeps raw).
|
|
67
67
|
- **Handlers named by the framework's convention are routes.** `haskell-web.js` adds `getFooR`/`postFooR`/`handleFooR` functions whose signature mentions `Handler` in a module importing Yesod when no route table in an analysed file names them (the table usually lives in `config/routes`). Scotty's rejection order is judged by `inlineOrder` as well as by line: a `case` lists the success branch first, so `status 40x >> finish` in the LAST alternative guards it (a bare `status` with no halting call does not).
|
|
68
68
|
- **IR idioms that must keep the action.** `m >>= \_ -> k` and `f <$> m` RUN `m` even when the lambda ignores its result (`betaReduce`, `via`); `maybe d f m` / `either f g e` are a two-way branch on the scrutinee (so the scrutinee is still examined); `\case` applied by `>>=` is a case run in place; `foldM`, `zipWithM_`, `uncurry`, `fst`/`snd`/`const` of a written-out tuple, `filter isAlphaNum`, and a let-bound lambda (`let go = \x -> ..`, treated as `go x = ..`) are lowered so their sinks are reached. The taint engine (`dataflow/engine.js`) is Haskell-scoped for three of these: a `union` handed to a combinator or bound as a statement is a sink site in each branch (`_nestedSinkFindings`, the `assign` case), and a `member` whose object is a call (the elements of `words s`) carries its object's taint.
|
|
69
|
-
- **A Haskell taint finding says who controls its input.** `haskell-trust.js` `applyTrustBoundary` (run in `engine.js` right after `applyHaskellGuards`) reads the origins the engine recorded (`sourceRoots`, or the evidence chain) and stamps `trustBoundary {verdict, reason, role, roots}`. Verdicts: `attacker-reachable` (request, socket, header, cookie, remote response; never lowered), `operator-controlled` (cli/env roots, and file-read roots in a command-line program; also an EMPTY origin in a command-line program, marked `inferred: true`), `api-contract` (a bare/whole-handoff parameter of an uncalled exported function reaching a process, CWE-78/88
|
|
69
|
+
- **A Haskell taint finding says who controls its input.** `haskell-trust.js` `applyTrustBoundary` (run in `engine.js` right after `applyHaskellGuards`) reads the origins the engine recorded (`sourceRoots`, or the evidence chain) and stamps `trustBoundary {verdict, reason, role, roots}`. Verdicts: `attacker-reachable` (request, socket, header, cookie, remote response; never lowered), `operator-controlled` (cli/env roots, and file-read roots in a command-line program; also an EMPTY origin in a command-line program, marked `inferred: true`), `api-contract` (a bare/whole-handoff parameter of an uncalled exported function reaching a process or, since 0.195.9, a path operation: CWE-78/88/22), `entry-parameter`, `data-input`, `unattributed`. `operator-controlled` and `api-contract` are lowered to `info` (never dropped; `originalSeverity`, `controls`, `guardNote` kept), the same convention `haskell-guards.js` uses. **Standard input is covered by the command-line rule only for a path operation** (since 0.195.9: a command-line program reading a file NAME from its standard input is the operator naming a file, 14 of the 29 flagged path sites on the real-code sets; `getLine >>= callCommand` in `main` stays reported, pinned in `test/haskell/haskell-trust-boundary.test.js`). **Path wrappers became `api-contract` in 0.195.9**: `withFile name WriteMode` on a caller-named file is the vulnerable shape of three synthetic development splits and of no real-code true positive, while `renderFile path = responseFile .. path` was a flagged false positive on every real project that had it; the parameter under a fixed directory (`"/srv/" ++ name`) is still `entry-parameter` and still a finding. That is a decision against the synthetic labels, taken on the real-code sets; the development-set numbers that moved are recorded in the changelog. The role is `fn.hs.entryRole` from `assignEntryRoles` in `haskell-ir.js`: `cli` (reached only from `main`s whose reachable modules import no server framework or socket library), `service`, or `library` (no `main` reaches it; an incomplete call graph therefore yields `library`, never `cli`). Pinned both ways in `test/haskell/haskell-trust-boundary.test.js`.
|
|
70
70
|
- **Origins are recorded, not guessed** (`dataflow/engine.js`). The engine records a source only against the variable a source call was assigned to; three additions make an evidence chain begin at the real origin: a value derived from a source inherits its records (`_inheritSources`), a callee analysed under an entry-taint state names its tainted parameters with placeholder records (`_paramPlaceholders`) that `_mergeSummaryFindings` resolves to the sources of the arguments at the merging call site (`_resolveParams`), and a function return keeps the sources behind it (`returnSources`, followed through `from` into `sourceRoots`). A parameter annotated as a source (`hs:caller-controlled`, `hs:handler-param:*`) is recorded too (`matchAnnotationParamSources`, `source.kind: 'parameter'` so stable ids do not change). This is language-neutral (`test/dataflow-source-attribution.test.js` pins it on JavaScript). `System.IO.hGetContents`/`hGetLine`/`hGetChar` (and the Text/ByteString forms) are modelled as `file-read`, not `stdin`: they read whatever handle they are given.
|
|
71
71
|
- **Reachability must survive platform variants.** An import two modules both satisfy (the Posix and Windows variants behind an undecided CPP conditional) resolves to `unknown: ambiguous-import` with its `candidates`; `haskell-ir.js` records each candidate as a reference of the caller, so neither is an "uncalled entry point" whose parameters are caller-controlled. Without it every function of the platform module that was not chosen was reported.
|
|
72
72
|
- **"No caller can exist" is a verdict with preconditions, and `null !== false`.** `haskell-ir.js` `projectReachability` stamps `fn.hs.reachability {status, rootKind, holeReason}` (`entrypoint-reachable | unreachable-in-project | external-api | unknown`); `haskell-reachability.js` `applyHaskellReachability` copies it onto each IR-TAINT finding as `f.reachability {status, reason, premise, roots, holeReason}`, a record SEPARATE from `trustBoundary` (that one says who controls the input, this one whether a caller can exist; either may be `unknown`). `unreachable-in-project` needs ALL of: `completeScan` (runScan/engine thread it; a changed-file, PR, subdirectory or caller-supplied scan is a subset, under which every export looks uncalled), no `policy-excluded`/`size-skipped` Haskell, `entrypointCount > 0` (a `main`; the sealed single-file corpora have none, so the rule is structurally inert on them), `exposedModules !== null` (`haskell-manifests.js` `exposedModuleIndex`: cabal `exposed-modules`, or hpack `exposed-modules`; an hpack library that exposes everything, `-main-is`, `-F/-pgmF`, a plugin, a non-test `build-tool-depends`, a foreign library, Backpack signatures are all `null`), the function's module not exposed (re-exports count: `reexportClosure`), not an instance method (instance methods are ROOTS, so what they call is reached, but their own verdict is `unknown/instance-method`), and not reached from a root. Roots: every `main`, framework-handler-typed functions (`hs.entryHandler`), `foreign export`s, instance methods, exported functions of exposed modules, and `withheld-caller` roots. Gaps are bounded where they can be and global where they cannot: Template Haskell/quasi-quotes the allow-lists did not accept (`preprocessForSyntaxInfo(...).opaque`), a foreign export, the function cap, an unresolved name nothing defines = global holes; a declaration the parser withheld or an expression it could not lower makes that module's own functions and the exported functions of every module it can name (re-export closure) roots, because withheld code can call only what its module can see. A call through a function VALUE is not a gap (its callee was mentioned in value position, which is a reference the walk follows). Holes are `unknown`, never `unreachable`; the order of `holeReason` is `HOLE_PRIORITY`. Pinned in `test/haskell/haskell-reachability.test.js`; the pin at `haskell-trust-boundary.test.js` (a lone `Svc.hs`) asserts `unknown/no-entrypoint`. **Severity:** only `unreachable-in-project` with `premise === 'caller-controlled-parameter'` (every recorded origin is the weak parameter label) is lowered to `info` (`lowerFinding`, guard `_reachLowered`, composes with `_trustLowered`); a finding with a recorded read keeps its severity and may only rank lower (`posture/relevance.js` `languageVerdict`). The verdict is recomputed in place per context (`buildHaskellIR(files, {manifests, completeScan, scanHoles})`; the BUILD cache is keyed by sources alone and the manifests are parsed once per context, counted by `EXPOSED_INDEX_WORK`). `fn.hs.reachedBy` and `fn.hs.paramEntry` record how a function was reached and what its parameters were before `paramAnnotations` is removed; the removal itself is unchanged. `callersUnknown(fn)` (`haskell-security-rules.js`) reads the verdict for the partial-function and unbounded-read rules; `external-api` is deliberately answered as before (turning it on added seven flagged sites and no true positive on the development set). Nothing reads a comment, a name or a path. Measured effect on real code is in `bench/dag/dev-haskell/results/README.md` (it changed no severity there) and the recorded baseline is gated by `bench/haskell-precision`.
|
|
73
73
|
- **Comments never change a verdict, an id or a cluster.** `haskell-comments.js` finds Haskell comments lexically; a finding's `snippet` is `codeOfLine(line)` (code only), because the snippet feeds the stable id and the clone-shape hash, and both changed when someone edited a trailing comment. `test/haskell/haskell-comment-invariance.test.js` scans the examples and corpus-shaped cases as written, with every comment blanked, and with every comment (and a trailing comment on every line) replaced by text that lies in each direction; `bench/dag/dev-haskell/comment-invariance.mjs` does the same to a real project. An engine that varies is wrong.
|
|
74
74
|
- **Rules that read the function around a call** (`haskell-security-rules.js`): `hs-weak-hash` is a vulnerability only where `weakDigestUse` finds a use that depends on the digest's strength (compared, hashed secret, credential-shaped binding or function, handed to a signing/verifying call); otherwise it is emitted at `info`. `hs-unbounded-input-read` needs a socket handle, or standard input in a function nothing calls (`readsOutsideInput`). Partial-function and allocation rules use a parameter as caller-supplied only for a framework-handler parameter or an uncalled function (`fn.hs.reached`). A salt is not an unpredictability-sensitive value. A cookie whose whole value is constant is not a credential. Own-property lookups only (`Object.hasOwn`): `constructor` and `toString` are legal Haskell names and were reported as "Unsafe boundary: function Object() { [native code] }".
|
|
75
75
|
- **Fixes name the library the file uses.** `sqlDriverOf` picks `Database.PostgreSQL.Simple` or `Database.SQLite.Simple` from the file's own imports and refuses anything else; `hs-html-escape` needs blaze-html or a re-exporter; `hs-hash-sha256` refuses when the algorithm is named on another line (a type); `hs-path-takefilename` rewrites only a String/FilePath parameter spliced under a fixed directory in a FILE operation. Classes with no safe rewrite refuse with a class-level reason (`CLASS_REASONS`), `proposal: 'model-assisted'`, `tier: 'guidance-only'`, and every reason ends `The decision needed: ...` (returned as `decision`). Never invent a library API: the TLS, cookie, entropy and wai shapes were written against the package documentation and their tests type-check against DECLARED STUBS of that API shape, which proves coherence with the documented shape, not a build against the real package.
|
|
76
|
-
- **Haskell plans carry `tier`, `consequences` and (for attribute fixes) `expectEffective`.** `planHaskellFix` returns `tier: 'full-source-edit'` and a `consequences` array for every plan (a patch that changes behavior must say how: stored digests, rejected connections, HTTP-only cookies, a request-body limit), the way `nix-fix.js` does. Ten fixers: the six rewrites above plus `hs-tls-validate` (CWE-295, the `connection` package's `TLSSettingsSimple` literal `True` to `False`; OpenSSL `VerifyNone` refused), `hs-cookie-attributes` (CWE-1004, four framework shapes; the scotty helper is written out as the record update because the wrapped form is invisible to the rule), `hs-random-os-entropy` (CWE-338, only `name = randomIO` of type `IO <fixed-width integer>` in a file that already imports `System.Entropy`) and `hs-request-body-bound` (CWE-770, a MITIGATION: wai `strictRequestBody` becomes a bounded reader defined in the file, with a 1 MiB limit that is a declared default, not a measured need).
|
|
76
|
+
- **Haskell plans carry `tier`, `consequences` and (for attribute fixes) `expectEffective`.** `planHaskellFix` returns `tier: 'full-source-edit'` and a `consequences` array for every plan (a patch that changes behavior must say how: stored digests, rejected connections, HTTP-only cookies, a request-body limit), the way `nix-fix.js` does. Ten fixers: the six rewrites above plus `hs-tls-validate` (CWE-295, the `connection` package's `TLSSettingsSimple` literal `True` to `False`; OpenSSL `VerifyNone` refused), `hs-cookie-attributes` (CWE-1004, four framework shapes; the scotty helper is written out as the record update because the wrapped form is invisible to the rule), `hs-random-os-entropy` (CWE-338, only `name = randomIO` of type `IO <fixed-width integer>` in a file that already imports `System.Entropy`) and `hs-request-body-bound` (CWE-770, a MITIGATION: wai `strictRequestBody` becomes a bounded reader defined in the file, with a 1 MiB limit that is a declared default, not a measured need). Two more since 0.195.10, both MITIGATIONS written against the real-code true positives: `hs-html-escape-text` (CWE-79: scotty's `html $ mconcat ["<h1>", v, "</h1>"]`, or `html` of one literal-free expression, gets an escaper DEFINED IN THE FILE on every non-literal part; `htmlEscapers` recognises it by its entities, so the rescan goes quiet for the right reason; only with `Web.Scotty` and `Data.Text.Lazy` imported, never a nested construction or a renderer) and `hs-password-kdf-migrate` (CWE-916: the migration story the old refusal asked for, written out in the file: the old pure one-line `name salt password` of one text type is kept as `nameLegacy` for verification, `name` becomes PBKDF2-HMAC-SHA256 with a tag in front of the hex, `nameNeedsRehash` reads the tag, `nameVerify stored salt password` checks either form, every `stored == name salt password` in the file calls it, an export list gains the helpers; only with a cryptonite/crypton module imported, and REFUSED when another file compares with the function, because that comparison would lock those users out). `haskell-security-rules.js` `legacyVerifierOnly` then reports the kept weak hash at `info`: every use of the function, in every file, is an operand of an equality and one of those functions also calls a password KDF within one project hop (the migration shape, decided from the IR; `register salt pw = oldHash salt pw` or a comparison beside no KDF keeps the full finding). `defaultRescan` drops a finding the engine LOWERED to `info` for a stated reason (`originalSeverity`, or the legacy-verifier evidence) as it drops `proven-*` ones, so a patch that earns such a lowering passes the "original gone" gate; a finding that is `info` in its own right (an MD5 with no visible use) is still counted, so fixing it can still be shown to remove it. SSRF, argument injection, missing auth and hard-coded secrets keep refusing and name the decision. The compile-gated remediation suites set `GHC_ENVIRONMENT=-` in their own process: a host's package environment file (`cabal install --lib`) hid `bytestring` under the recorded GHC and turned every stub compile into not-run.
|
|
77
77
|
- **The effective-change check is not the rescan.** `haskellEffectiveCheck` (wired as the lifecycle's `effectiveCheck`) re-reads the intended attribute from the patched file with readers independent of the editor (`tlsSettingsOf`, `cookieSitesOf`, call counts) and fails when the planned site did not change or another site did. A cookie renamed to a non-session name silences the rule without adding an attribute; the check says so (`haskell-remediation-0190.test.js`).
|
|
78
78
|
- **The compile gate is requested by the preview.** `languageFixPreview` passes `compile: true` (`compile: false` opts out). `compileGate` type-checks only the patched file(s) (`targets`; local imports found through the `module` header's source root) and takes the original tree as `baseline`: a project that does not type-check on this host (a package it lacks), or a patch that imports a module this host lacks, is `{ran:false, ok:null}` (not-run), a patch that breaks a compiling file is `{ran:true, ok:false}`. A missing GHC is not-run, never a pass. `requireCompile` turns not-run into a block.
|
|
79
79
|
- **Witnesses.** `witness.js` `haskellFixWitness(finding)` runs the original and the patched file under `ghc -e ':load *File' -e 'fn "<payload>"'` in the sandbox and judges them with `judgeFixWitness`. It is built only for command-injection fixes in a top-level `String -> IO` function whose file imports only `bootModuleSet` modules; every other class returns `verified: false` with the reason (a TLS peer, a framework response header, a database, a digest, a distribution cannot be shown by a program GHC runs alone). Opt in with `languageFixPreview(..., { witness: true })`.
|
|
@@ -143,13 +143,24 @@ Pinned both ways in `test/nix/nix-option-types.test.js`.
|
|
|
143
143
|
|
|
144
144
|
The `unseen` split (unseen-v3) is a **DEVELOPMENT set since 2026-10-26**: its failures were read and the engine was changed for their causes (provenance.json, `unseen-v3-development`; manifest `role: development`). `measure.mjs` records `splitRole` with every result, the registry labels the column "DEVELOPMENT set", and a generalisation claim needs an unseen set no engine change has been motivated by.
|
|
145
145
|
|
|
146
|
-
**Frozen splits (`bench/language-support/corpora/unseen-v4`, `unseen-v5`, ...).** Each is 78 single-flaw Haskell forms (13 families, 3 vulnerable and 3 safe) written blind to the engine and to every earlier set, frozen by digest (`corpora/frozen-split.mjs` is the shared builder, loader and summariser; `test/language/frozen-splits.test.js` checks every split on every run), measured once before the engine work that follows, and governed by its `USE-LOG.md`: reading its failures to change a rule turns it into a development set (`manifest.json` `role: development`, `developmentSince`), and the next generalisation claim needs the next split, written the same way (a separate agent session that cannot see the repository). **unseen-v4
|
|
146
|
+
**Frozen splits (`bench/language-support/corpora/unseen-v4`, `unseen-v5`, ...).** Each is 78 single-flaw Haskell forms (13 families, 3 vulnerable and 3 safe) written blind to the engine and to every earlier set, frozen by digest (`corpora/frozen-split.mjs` is the shared builder, loader and summariser; `test/language/frozen-splits.test.js` checks every split on every run), measured once before the engine work that follows, and governed by its `USE-LOG.md`: reading its failures to change a rule turns it into a development set (`manifest.json` `role: development`, `developmentSince`), and the next generalisation claim needs the next split, written the same way (a separate agent session that cannot see the repository). **unseen-v4 and unseen-v5 are DEVELOPMENT sets since 2026-11-01** and **unseen-v6 is the Haskell generalisation measure; unseen-nix-v1 is the first Nix one** (the builder takes `eco`, a split's manifest records `ecosystem`, and a Nix form's marker is `#@@`). The lesson of 0.195.8 is the reason the discipline exists: the unseen-v4 misses were fixed (F1 64.7% to 94.9% on v4) and unseen-v5 moved from F1 63.8% to 65.7%, so most of those fixes answered v4's shapes, not Haskell's. 0.195.9 read unseen-v5 after freezing unseen-v6: v5 went from F1 65.7% to 84.1% and v6 from 62.9% to 72.5%, so this time part of it generalised (the Servant pairing from the end, the fixed-host request judgement, the credential header names, the harness's proven-clean rule). The Nix development set (unseen-v3) went to 100% and unseen-nix-v1 stayed at F1 71.0%: none of that generalised. Measure a new split before reading the old one; quote both numbers.
|
|
147
|
+
|
|
148
|
+
**The detection suite scores `actionable` findings (since 0.195.9):** a finding at `info`, or one the engine PROVED clean or infeasible (`proof.verdict` `proven-*`: a catalog sanitizer of the sink's family on the path, a dominating guard), is not a detection, the same rule `bench/language-support/lib.mjs` `actionable` already applied elsewhere. Before, `renderHtml (toHtml name)` into a Scotty `html` sink was scored a false positive although the proof gate had labelled it proven-clean. **Disclosure:** while the real-code precision work of 0.195.9 was being investigated, two frozen-holdout path-traversal sources (shapes s0 and s1, both a parameter joined under a fixed directory) were printed and read; no rule was changed to them (both are still findings), but the holdout has been seen by the person who changed the path-wrapper policy, and a reader weighing the holdout's independence should know that.
|
|
149
|
+
|
|
150
|
+
Rules added for the unseen-v5 shapes, pinned both ways in `test/haskell/haskell-unseen-v5-shapes.test.js`: a Servant handler pairs the API's reads with its LAST parameters (`orderServer conn ref`; `haskell-ir.js` `servantEndpointParams` paired from the front and annotated the closure parameter); a `Set-Cookie` header handed to `setHeader`/`addHeader` is judged like a hand-written tuple; a project helper the IR inlined at its call site (`hs.inlinedFrom`) reports a fast password hash or a credential log once, in the helper (`markInlined`, `inlineCtx`, the filter before the return); mwc-random and splitmix are predictable generators (`System.Random.MWC` in `HS_RULE_APIS` so the IR qualifies the names); a function that MAKES a key (`newKey`, `generateApiKey`) is security-shaped, and `code`/`pin`/`otp` is a security value in a function that talks about a reset or a verification (`CONTEXT_CODE_VAR`, `CODE_CONTEXT`); an authentication endpoint (`AUTH_ENDPOINT_PATH`/`_NAME`) is never missing authentication; `X-Session`, `X-Token` and similar project header names are credentials (`CREDENTIAL_HEADER`); a yes/no answer about a credential (`accepted <- checkCredentials attempt`, a comparison, a `logical`, a helper whose returns are such: `returnsYesNo`) carries no credential text, a record built from one logged through another field (`attemptUser attempt`, a `member` of it) is that field, the record logged whole is the credential, and `lastFour`/`takeEnd`/`fingerprint` are masking helpers by name; `pushLogStr(Ln)` is a log sink. In `haskell-guards.js`: a CWE-918 flow whose URL starts from a literal (or a constant bound to one) naming the scheme, the host and a `/`, or whose request is built by http-conduit's modifiers over such a base, is lowered to `info` with `guardNote.kind: fixed-host-request` (`fixedHostRequest`; a record update that sets a host field, or a literal that stops inside the host, is not); `judgeFinding` locates the sink in the CALLEE when the finding was found from a caller (the canonicalise-and-prefix guard in `persistDraft` refutes the flow found from `saveDraft`), and the XSS escaper judgement knows the catalog's HTML escapers and a project helper that uses the parameter receiving the tainted value only under an escaper. Nix (`test/nix/nix-development-shapes.test.js`): `AmbientCapabilities` as one string, `Defaults !authenticate`, a `*File` option handed a `writeText` of a literal (`nix-secret-plaintext`, exposure `store`), a TLS key path on any enabled service (`sslKey`, `sslServerKey`, `keyFile`, ...), and one script-taint finding per rule, line and origin class.
|
|
147
151
|
|
|
148
152
|
Rules added for the unseen-v4 shapes, each pinned both ways in `test/haskell/haskell-unseen-v4-shapes.test.js`: a value READ from the request inside a function (`param`, `strictRequestBody`, `requestHeaders`, any `REQUEST_PROVENANCE` source) is caller text for the partial-function, allocation and bounding rules whatever the function's parameters are; a presence test does not cover a partial call on a value SPLIT or parsed from the tested one (`SPLITTER`), while the same split of the same value does; a relational test on a count bounds the allocation it sizes; a `System.Random` value that reaches the result of a security-shaped function is predictable randomness; a project record parameter with a credential or card field logged whole (`show card`) is sensitive logging, through a masking helper by name it is not; wai-extra `requestSizeCheck` bounds the body read. In `haskell-web.js`: Servant closure arguments before the endpoint's values are paired from the end; a container lookup by the request id (`M.lookup`) is the object selection for BOLA only; BOLA without an authenticated principal needs either a WRITE to the selected object or OWNER EVIDENCE (`OWNER_COL`: a statement names an owner column its WHERE clause does not use; `OWNER_FIELD`: the record the handler works with has an owner field it never reads, where "works with" means another field of that record is read); a Scotty handler typed `ActionM` but registered elsewhere is a route with method `UNKNOWN` (ownership only, never a missing-auth finding); a Yesod `isAuthorized` clause that can answer `AuthenticationRequired` guards; a privileged path whose handler calls into unseen code is a lower-severity missing-auth finding, but a plain write with no visible operation is NOT (23 echo, upload-demo and health-check routes on four real projects). Taint: `Network.Wai.Parse.fileName` is an `upload` source; `Network.Wai.responseLBS`/`responseBuilder` are CWE-79 sinks judged by the header argument (`waiHtmlContext`: `text/html` keeps, another content type lowers to `info`, opaque headers keep with a stated uncertainty); the HTML-escaper judgement follows local bindings, and a definition with no value in it (a read) is the raw origin; a Shakespeare quasi-quote with interpolations is rewritten in place to an application of its interpolated expressions (`hamletInterpolations`, boundary kind `quasiquote-hamlet`, never opaque) so `#{preEscapedToHtml body}` is a sink site and `#{body}` is not. Two shapes stay as they were on purpose: `getArgs` reaching a shell is `operator-controlled` (info), and `fetch url = parseRequest url >>= httpNoBody` stays a finding (a URL wrapper is not an `api-contract`); unseen-v4 labels both the other way, and that is recorded, not decided.
|
|
149
153
|
|
|
150
154
|
The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
|
|
151
155
|
against the holdout, and any further holdout run counts as repeated use. A change to the engine, the corpus or a label changes the
|
|
152
156
|
frozen hashes and demotes rows until a deliberate re-measure. `docs/language-support.*` are generated; do not edit by hand.
|
|
157
|
+
**The GHC on PATH must be the recorded toolchain** (`docs/language-toolchain.json`, 9.4.8): the route fixtures' packages are installed per
|
|
158
|
+
GHC version, so another GHC fails the `auth` compile criterion for a missing package and the registry would record an engine failure that
|
|
159
|
+
is not one (it happened on 2026-11-01). Since 0.195.10 `probeTools` records `ghcVersion` and whether it matches, `promote.mjs` refuses to
|
|
160
|
+
measure the suites under a mismatch (`--allow-ghc-mismatch` records it in the limits instead), the AC01 criterion's message says
|
|
161
|
+
"unavailable compiler" under a mismatch so the row is `blocked` rather than `failed`, and `judgeGhc` is pinned both ways in
|
|
162
|
+
`test/language/language-package-ci.test.js`. To re-run only the suites, delete `bench/language-support/results/suites.json` and run
|
|
163
|
+
`promote.mjs --reuse` (the holdout result is reused; the suites are measured again).
|
|
153
164
|
|
|
154
165
|
## Docs generated from here
|
|
155
166
|
|
|
@@ -703,7 +703,225 @@ 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
|
+
|
|
706
923
|
const FIXERS = [
|
|
924
|
+
{ 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
925
|
{ id: 'hs-tls-validate', applies: (f) => f.cwe === 'CWE-295' || f.rule === 'hs-tls-validation-disabled', run: fixTls, reportedLineOnly: true },
|
|
708
926
|
{ id: 'hs-cookie-attributes', applies: (f) => f.cwe === 'CWE-1004' || f.rule === 'hs-insecure-cookie', run: fixCookie, reportedLineOnly: true },
|
|
709
927
|
{ id: 'hs-random-os-entropy', applies: (f) => f.cwe === 'CWE-338' || f.rule === 'hs-predictable-random', run: fixRandom, reportedLineOnly: true },
|
|
@@ -711,6 +929,7 @@ const FIXERS = [
|
|
|
711
929
|
{ id: 'hs-sql-parameterize', applies: (f) => f.cwe === 'CWE-89', run: fixSql },
|
|
712
930
|
{ id: 'hs-process-argv', applies: (f) => f.cwe === 'CWE-78', run: fixProcess },
|
|
713
931
|
{ id: 'hs-html-escape', applies: (f) => f.cwe === 'CWE-79', run: fixHtml },
|
|
932
|
+
{ id: 'hs-html-escape-text', applies: (f) => f.cwe === 'CWE-79', run: fixHtmlText, reportedLineOnly: true },
|
|
714
933
|
{ id: 'hs-hash-sha256', applies: (f) => f.cwe === 'CWE-328' || f.rule === 'hs-weak-hash', run: fixWeakHash },
|
|
715
934
|
{ id: 'hs-log-redact', applies: (f) => f.cwe === 'CWE-532' || f.rule === 'hs-sensitive-logging', run: fixLogging },
|
|
716
935
|
{ id: 'hs-path-takefilename', applies: (f) => f.cwe === 'CWE-22', run: fixPath },
|
|
@@ -761,7 +980,7 @@ export function planHaskellFix(finding, files) {
|
|
|
761
980
|
// 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
981
|
// helper for all of its callers: the scotty cookie helpers, for instance, are the library's own definitions).
|
|
763
982
|
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; }
|
|
983
|
+
let r = null; try { r = fx.run(before, { ...finding, line, _files: files }); } catch { r = null; }
|
|
765
984
|
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
985
|
}
|
|
767
986
|
}
|
|
@@ -787,7 +1006,7 @@ const DEFAULT_CONSEQUENCES = Object.freeze({
|
|
|
787
1006
|
const CLASS_REASONS = Object.freeze({
|
|
788
1007
|
'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
1008
|
'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).',
|
|
1009
|
+
'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
1010
|
'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
1011
|
'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
1012
|
'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).',
|
|
@@ -796,7 +1015,7 @@ const CLASS_REASONS = Object.freeze({
|
|
|
796
1015
|
'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
1016
|
'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
1017
|
'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
|
|
1018
|
+
'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
1019
|
'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
1020
|
});
|
|
802
1021
|
|
|
@@ -810,8 +1029,12 @@ export async function defaultRescan(files) {
|
|
|
810
1029
|
try {
|
|
811
1030
|
for (const [f, t] of Object.entries(files)) { mkdirSync(dirname(join(dir, f)), { recursive: true }); writeFileSync(join(dir, f), t); }
|
|
812
1031
|
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
|
-
|
|
1032
|
+
// 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
|
|
1033
|
+
// engine LOWERED to information for a reason it states (`originalSeverity`: a recognised guard, an operator-controlled origin; the legacy
|
|
1034
|
+
// 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
|
|
1035
|
+
// whose use is not visible) is still counted, so a fix of it can still be shown to have removed it.
|
|
1036
|
+
const lowered = (f) => f.severity === 'info' && (f.originalSeverity || (f.evidence && f.evidence.legacyVerifier === true));
|
|
1037
|
+
return (r.scan.findings || []).filter((f) => !(f.proof && /^proven-/.test(f.proof.verdict)) && !lowered(f));
|
|
815
1038
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
816
1039
|
}
|
|
817
1040
|
|
|
@@ -917,6 +1140,29 @@ export function haskellEffectiveCheck(plan, patched) {
|
|
|
917
1140
|
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
1141
|
return { ok: true, ran: true, detail: `${want.replaced} strictRequestBody call(s) were replaced by the bounded reader, whose limit is the declared constant` };
|
|
919
1142
|
}
|
|
1143
|
+
if (want.kind === 'html-text') {
|
|
1144
|
+
const code = stripComments(after);
|
|
1145
|
+
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' };
|
|
1146
|
+
if (!after.includes(HTML_TEXT_HELPER)) return { ok: false, ran: true, detail: 'the escaper is not defined in the patched file' };
|
|
1147
|
+
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;
|
|
1148
|
+
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` };
|
|
1149
|
+
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` };
|
|
1150
|
+
}
|
|
1151
|
+
if (want.kind === 'kdf') {
|
|
1152
|
+
const code = stripComments(after); const ls = code.split('\n');
|
|
1153
|
+
const defs = (nm) => ls.filter((l) => new RegExp(`^${nm}\\s+[a-z_][\\w']*\\s+[a-z_][\\w']*\\s*=`).test(l));
|
|
1154
|
+
if (defs(want.legacy).length !== 1) return { ok: false, ran: true, detail: `${want.legacy} is not defined exactly once` };
|
|
1155
|
+
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*/, '');
|
|
1156
|
+
if (!oldBody || defs(want.legacy)[0].replace(/^[^=]*=\s*/, '') !== oldBody) return { ok: false, ran: true, detail: `${want.legacy} is not the original definition kept verbatim` };
|
|
1157
|
+
const nd = defs(want.fn);
|
|
1158
|
+
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` };
|
|
1159
|
+
const vd = ls.filter((l) => new RegExp(`^${want.verify}\\s+stored\\s`).test(l));
|
|
1160
|
+
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` };
|
|
1161
|
+
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;
|
|
1162
|
+
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;
|
|
1163
|
+
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` };
|
|
1164
|
+
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` };
|
|
1165
|
+
}
|
|
920
1166
|
return { ok: false, ran: true, detail: `unknown effective check ${want.kind}` };
|
|
921
1167
|
}
|
|
922
1168
|
|