fix(taint): resolve lambda bodies flow-sensitively against final taints#19
Conversation
PR #18 made sink DISCOVERY (_own_calls) descend into lambda bodies, but the L2 taint walk's _resolve_expr resolved only lambda *defaults*, never the body — so a sink call inside a lambda body had no entry in function_call_site_arg_taints and worst_arg_taint fell to the pessimistic flow-insensitive fallback (WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK), firing UNKNOWN_RAW for ANY lambda-body sink in a trusted function regardless of the real argument taint (false positives on 'lambda: eval("safe")' and a shadowing 'lambda cmd: eval(cmd)'). Resolve lambda bodies in a second pass (_resolve_lambda_bodies) AFTER the forward walk finalises var_taints, against those FINAL taints. Final-state is the sound choice for a closure: a lambda defers execution and captures free variables by reference, so a variable assigned raw AFTER the lambda is defined ('src="safe"; cb=lambda: eval(src); src=read_raw(p)') is a real deferred sink and must still fire — a definition-site pass would silently miss it (a false negative, the cardinal sin for a fail-closed analyzer). Each body is resolved in an isolated scope copy so lambda-local bindings (params, walrus) never leak, and the lambda's own parameters are reset to the neutral seed (function_taint) so they shadow enclosing names rather than inheriting their taint. Net effect is a strict improvement vs main: real and deferred lambda-body sinks fire flow-sensitively (no warning); clean, final-state-clean, and shadowed-param values no longer false-fire. Full suite green (coverage 91.77%); mypy clean; dogfood self-scan unchanged (0 new). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 95d1139bab
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for param in (*args.posonlyargs, *args.args, *args.kwonlyargs): | ||
| scope[param.arg] = function_taint |
There was a problem hiding this comment.
Preserve tainted lambda arguments before resolving bodies
When a lambda-body sink reads one of the lambda's parameters, seeding every parameter with function_taint records that sink argument as clean and disables the previous pessimistic fallback. In a trusted function such as cb = lambda cmd: eval(cmd); cb(read_raw(p)) (or a tainted default like lambda cmd=read_raw(p): eval(cmd)), eval(cmd) now gets a flow-sensitive entry of ASSURED, so PY-WL-107 is suppressed even though raw data reaches the sink at runtime.
Useful? React with 👍 / 👎.
| for lam in _own_scope_lambdas(func_node): | ||
| scope = dict(final_var_taints) |
There was a problem hiding this comment.
Keep outer lambda locals when resolving nested lambda bodies
Because _own_scope_lambdas also visits lambdas nested inside other lambda bodies, rebuilding each nested body from only final_var_taints drops local bindings created while resolving the enclosing lambda. For example, in cb = lambda: ((x := read_raw(p)), (lambda: eval(x))()), the outer lambda's local x is raw before the inner lambda runs, but the inner eval(x) is later re-resolved from the function's final scope and recorded as ASSURED, suppressing PY-WL-107 instead of flagging the raw sink.
Useful? React with 👍 / 👎.
| for lam in _own_scope_lambdas(func_node): | ||
| scope = dict(final_var_taints) |
There was a problem hiding this comment.
Do not use final state for lambdas called before cleanup
Resolving every lambda body from final_var_taints loses the taint that exists at an earlier lambda invocation. In a trusted function like x = read_raw(p); cb = lambda: eval(x); cb(); x = "clean", the sink executes while x is raw, but this pass records eval(x) as INTEGRAL from the final assignment and PY-WL-107 no longer fires; the old fallback would at least have reported the sink.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR improves Wardline’s L2 variable-taint engine so that sink calls found inside lambda bodies are resolved using a second pass against the function’s final variable taints, eliminating the prior flow-insensitive fallback warning and reducing false positives for clean/shadowed lambda-body sinks.
Changes:
- Add a second-pass lambda-body resolver (
_resolve_lambda_bodies) and lambda discovery helper (_own_scope_lambdas) to populate call-site argument taints for calls inside lambda bodies. - Expand sink-rule regression tests to cover flow-sensitive lambda-body behavior (fires on real/deferred taint; no-fire on clean/shadowed/final-clean cases) and assert no fallback warning is emitted.
- Update the changelog entry to reflect lambda-body traversal and the new flow-sensitive resolution behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/wardline/scanner/taint/variable_level.py |
Implements second-pass lambda-body taint resolution to avoid flow-insensitive fallback for lambda-body sinks. |
tests/unit/scanner/rules/test_sink_rules.py |
Adds regression tests for lambda-body sink resolution and warning suppression. |
CHANGELOG.md |
Documents the lambda-body taint-walk behavior and the resulting FP/FN impact. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| args = lam.args | ||
| for param in (*args.posonlyargs, *args.args, *args.kwonlyargs): | ||
| scope[param.arg] = function_taint | ||
| if args.vararg is not None: | ||
| scope[args.vararg.arg] = function_taint | ||
| if args.kwarg is not None: | ||
| scope[args.kwarg.arg] = function_taint | ||
| _resolve_expr(lam.body, function_taint, taint_map, scope) |
| def test_107_raw_reaches_eval_in_lambda_body_is_flow_sensitive(tmp_path) -> None: | ||
| # The lambda-body sink is resolved flow-sensitively (real taint), NOT via the | ||
| # pessimistic flow-insensitive fallback — so it still fires, but emits no warning. | ||
| ctx = _analyze( | ||
| tmp_path, |
#20) #19 resolved lambda bodies against the function's FINAL var taints. That fixed the deferred case where a captured variable is tainted AFTER the lambda is defined, but introduced the symmetric false negative: a variable still raw WHEN the lambda is called, cleaned only afterwards, was recorded clean and the sink suppressed — src = read_raw(p); cb = lambda: eval(src); cb(); src = "clean" # eval runs on RAW (reported high-severity, validated). A closure captures by reference and runs at an unknown call time, so NO single program-point snapshot is sound: def-site misses 'tainted after', final-state misses 'called before cleanup'. Resolve each lambda body against the WORST (least-trusted) taint each captured variable holds ANYWHERE in the function — joined over every per-statement snapshot plus the final state (_worst_ever_var_taints). This is the fail-closed choice: it guarantees no false-negative across both deferred orderings. The recording requires the per-statement snapshots; without them the lambda pass is skipped so the sink rules fall back to their pessimistic UNKNOWN_RAW default rather than a possibly-clean final value (never silently masking a sink). Residual cost is a documented, conservative, waivable FALSE POSITIVE: a variable raw only BEFORE the lambda captures it is treated tainted (the analysis joins over the whole function, not the capture point). The safe direction; dogfood self-scan unchanged (0 new). Full suite green (coverage 91.78%), mypy clean. Co-authored-by: John Morrissey <john@wardline.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Motivation
Follow-up to #18. That PR made sink discovery (
_own_calls) descend into lambda bodies, but the L2 taint walk still resolved only lambda defaults, never the body. So a sink call inside a lambda body had no entry infunction_call_site_arg_taints, andworst_arg_taintfell to the pessimistic flow-insensitive fallback (WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK) — firingUNKNOWN_RAWfor any lambda-body sink in a trusted function regardless of the real argument taint.That over-fired (false positives) on clean and shadowed-parameter cases, e.g.
lambda: eval("safe")andlambda cmd: eval(cmd).Fix
Resolve lambda bodies in a second pass (
_resolve_lambda_bodies), after the forward walk finalisesvar_taints, against those final taints.Final-state resolution is the sound choice for a closure: a lambda defers execution and captures free variables by reference, so a variable assigned raw after the lambda is defined is a real deferred sink and must still fire — a definition-site pass would silently miss it (the cardinal sin for a fail-closed analyzer):
Each body is resolved in an isolated scope copy (lambda-local params/walrus never leak), with the lambda's own parameters reset to the neutral seed so they shadow enclosing names rather than inheriting their taint.
Net effect (strict improvement over
main)main(post-#18)evalin lambda bodylambda: eval("safe")lambda cmd: eval(cmd)(param shadows raw)No
WLN-ENGINE-FLOW-INSENSITIVE-FALLBACKwarning remains for lambda-body sinks.Testing
test_sink_rules.py(fire on real + deferred taint acrossPY-WL-107/108; no-fire on clean local, final-state-clean reassignment, shadowing param).ruff check/formatclean;mypyclean.🤖 Generated with Claude Code