fix(taint): resolve lambda bodies against worst-ever var taint (fixes #19 FN)#20
Merged
Merged
Conversation
#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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tachyon-beep
pushed a commit
that referenced
this pull request
Jun 5, 2026
The PR's original regression test exercised the free-variable case (`src = read_raw(p); cb = lambda: eval(src); cb(); src = "clean"`), which the worst-ever-taint pass (#20) already covers — it fires without this change, so it guards nothing. Replace it with tests for the defect this change actually closes: a raw value bound to a lambda PARAMETER at a direct or inline call site (`cb = lambda x: eval(x); cb(raw)` and `(lambda x: eval(x))(raw)`). The worst-ever pass resets lambda params to neutral and so cannot see the argument taint — these were silent false-negatives (no fallback warning) on the prior engine and now fire via call-time resolution. Add a clean-arg negative guard against the call-time path over-firing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tachyon-beep
added a commit
that referenced
this pull request
Jun 5, 2026
* fix(taint): resolve direct lambda calls at call time * test(taint): guard the real arg-binding lambda FN this change fixes The PR's original regression test exercised the free-variable case (`src = read_raw(p); cb = lambda: eval(src); cb(); src = "clean"`), which the worst-ever-taint pass (#20) already covers — it fires without this change, so it guards nothing. Replace it with tests for the defect this change actually closes: a raw value bound to a lambda PARAMETER at a direct or inline call site (`cb = lambda x: eval(x); cb(raw)` and `(lambda x: eval(x))(raw)`). The worst-ever pass resets lambda params to neutral and so cannot see the argument taint — these were silent false-negatives (no fallback warning) on the prior engine and now fire via call-time resolution. Add a clean-arg negative guard against the call-time path over-firing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: John Morrissey <john@wardline.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Fixes a validated high-severity false negative introduced by #19 (commit
d99d336).#19 resolved lambda bodies against the function's final variable 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:
The non-lambda control (
src = read_raw(p); eval(src); src = "clean") correctly fires PY-WL-107; the lambda version did not.Root cause
A closure captures variables by reference and runs at an unknown call time ≥ its definition. So no single program-point snapshot is sound: definition-site misses "tainted after", final-state misses "called before cleanup". Both #18 (def-site) and #19 (final-state) picked one point and each missed one ordering.
Fix
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.When the per-statement snapshots are unavailable (a degraded caller that doesn't request flow-sensitive recording), the lambda pass is skipped so the sink rules fall back to their pessimistic
UNKNOWN_RAWdefault — never silently masking a sink with a possibly-clean final value.Soundness / precision
evalin lambda bodylambda: eval("safe")(never raw)lambda cmd: eval(cmd)(param shadows raw)Documented residual: a conservative, waivable false positive — a variable raw only before the lambda captures it (
x = read_raw(p); x = "clean"; cb = lambda: eval(x)) is treated tainted, because the analysis joins over the whole function rather than tracking the capture point. The safe direction for a security analyzer, pinned by a regression test, and verified not to fire on wardline's own source (dogfood: 0 new).Testing
test_sink_rules.py: both deferred orderings fire (PY-WL-107/108), no-fire on clean local and shadowing param, and the documented conservative FP.ruff+mypyclean; dogfood 0 new.🤖 Generated with Claude Code