Skip to content

fix(taint): resolve lambda bodies flow-sensitively against final taints#19

Merged
tachyon-beep merged 1 commit into
mainfrom
fix/lambda-body-flow-sensitive-taint
Jun 5, 2026
Merged

fix(taint): resolve lambda bodies flow-sensitively against final taints#19
tachyon-beep merged 1 commit into
mainfrom
fix/lambda-body-flow-sensitive-taint

Conversation

@tachyon-beep

Copy link
Copy Markdown
Collaborator

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 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.

That over-fired (false positives) on clean and shadowed-parameter cases, e.g. lambda: eval("safe") and lambda cmd: eval(cmd).

Fix

Resolve lambda bodies in a second pass (_resolve_lambda_bodies), after the forward walk finalises var_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):

@trusted(level='ASSURED')
def f(p):
    src = "safe"
    cb = lambda: eval(src)
    src = read_raw(p)     # tainted AFTER the lambda is defined
    return cb()           # eval() runs on raw → must fire

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)

Case main (post-#18) This PR
raw → eval in lambda body fires (+ warning) fires, flow-sensitive, no warning
raw assigned after lambda def (deferred) fires (fallback) fires (sound)
lambda: eval("safe") false-fires no fire
lambda cmd: eval(cmd) (param shadows raw) false-fires no fire
value clean in final state false-fires no fire

No WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK warning remains for lambda-body sinks.

Testing

  • 6 new regression tests in test_sink_rules.py (fire on real + deferred taint across PY-WL-107/108; no-fire on clean local, final-state-clean reassignment, shadowing param).
  • Full suite green: 2355 passed, coverage 91.77% (≥90 gate); ruff check/format clean; mypy clean.
  • Dogfood self-scan unchanged (0 new findings on wardline's own source).

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings June 5, 2026 06:49
@tachyon-beep
tachyon-beep merged commit d99d336 into main Jun 5, 2026
10 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +203 to +204
for param in (*args.posonlyargs, *args.args, *args.kwonlyargs):
scope[param.arg] = function_taint

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +200 to +201
for lam in _own_scope_lambdas(func_node):
scope = dict(final_var_taints)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +200 to +201
for lam in _own_scope_lambdas(func_node):
scope = dict(final_var_taints)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +202 to +209
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)
Comment on lines +178 to +182
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,
tachyon-beep added a commit that referenced this pull request Jun 5, 2026
#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>
@tachyon-beep
tachyon-beep deleted the fix/lambda-body-flow-sensitive-taint branch June 13, 2026 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants