Skip to content

fix(taint): resolve lambda bodies against worst-ever var taint (fixes #19 FN)#20

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

fix(taint): resolve lambda bodies against worst-ever var taint (fixes #19 FN)#20
tachyon-beep merged 1 commit into
mainfrom
fix/lambda-body-worst-ever-taint

Conversation

@tachyon-beep

Copy link
Copy Markdown
Collaborator

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:

@trusted(level='ASSURED')
def f(p):
    src = read_raw(p)
    cb = lambda: eval(src)
    cb()                # eval() runs on RAW data here
    src = "clean"       # cleanup happens AFTER the call

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_RAW default — never silently masking a sink with a possibly-clean final value.

Soundness / precision

Case result
raw → eval in lambda body fires
raw assigned after lambda def (deferred) fires
raw at call, cleaned after (the reported FN) fires
lambda: eval("safe") (never raw) no fire
lambda cmd: eval(cmd) (param shadows raw) no fire

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

  • New/updated regression tests in 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.
  • Full suite 2356 passed, coverage 91.78%; ruff + mypy clean; dogfood 0 new.

🤖 Generated with Claude Code

#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
tachyon-beep merged commit 69dd322 into main Jun 5, 2026
9 checks passed
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>
@tachyon-beep
tachyon-beep deleted the fix/lambda-body-worst-ever-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.

1 participant