Skip to content

fix(taint): resolve direct lambda calls at call time#22

Merged
tachyon-beep merged 2 commits into
mainfrom
codex/fix-lambda-sink-taint-analysis-issue
Jun 5, 2026
Merged

fix(taint): resolve direct lambda calls at call time#22
tachyon-beep merged 2 commits into
mainfrom
codex/fix-lambda-sink-taint-analysis-issue

Conversation

@tachyon-beep

Copy link
Copy Markdown
Collaborator

Motivation

  • Lambdas that are defined then called before a later cleanup assignment could be misclassified when lambda bodies were resolved only against the function's final variable taints, producing false negatives for sinks inside direct lambda calls.
  • The analyzer must record call-site argument taints for calls inside lambda bodies using the taints visible when the lambda actually executes for direct/inline calls, while preserving the existing final-state pass for deferred/escaping lambdas.

Description

  • Add a contextvar _CURRENT_LAMBDA_BINDINGS to track local names bound to ast.Lambda nodes during the variable-level walk and seed/reset it in compute_variable_taints.
  • Implement _resolve_lambda_body_at_call to resolve a lambda body against the current enclosing var_taints plus the call's resolved positional/keyword taints and call it at direct call sites and inline lambda calls from _resolve_call.
  • Merge/append recorded call_site_arg_taints conservatively instead of overwriting so earlier call-time raw taints cannot be laundered by a later final-state lambda pass.
  • Bind/unbind lambda names on assignment and annotated assignment so the analyzer knows when a local name refers to a lambda, and add a regression test test_107_lambda_called_before_later_clean_assignment_still_fires to cover the reported PoC case.

Testing

  • Ran uv run ruff check on the modified files and it completed with no issues (passed).
  • Ran uv run mypy on the modified files and it completed with no issues (passed).
  • Verified python -m py_compile on the modified module and it completed successfully (passed).
  • Ran the unit tests in tests/unit/scanner/rules/test_sink_rules.py with a temporary minimal yaml.safe_load shim (to avoid installing PyYAML in the environment) and all relevant tests passed (31 passed).
  • Running the full unit test run without PyYAML failed due to the environment lacking PyYAML (dependency installation via the environment tunnel also failed), so the full test run could not be completed in this environment (failure due to missing optional dependency).

Codex Task

@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: cb0145e778

ℹ️ 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 +628 to +630
if isinstance(node.func, ast.Name) and lambda_bindings is not None and node.func.id in lambda_bindings:
_resolve_lambda_body_at_call(
lambda_bindings[node.func.id],

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 all branch lambda bindings before direct calls

When a name can be bound to different lambdas in different branches, this single lambda_bindings[node.func.id] lookup only resolves whichever branch happened to be walked last. For example, if cond: cb = lambda: eval(src) else: cb = lambda: eval('safe'); cb(); src = 'clean' misses the raw eval(src) path: the direct-call pass records only the else lambda, and the if lambda is later resolved by the final-state pass after src has been cleaned. The binding state needs to be branch-local/merged conservatively rather than overwritten by traversal order.

Useful? React with 👍 / 👎.

Comment on lines +237 to +238
for param in positional_params[len(pos_taints) :]:
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 Use lambda defaults for omitted direct-call parameters

When a direct call omits a lambda parameter that has a default, this seeds the parameter with function_taint instead of the default expression's taint. For src = read_raw(p); cb = lambda x=src: eval(x); cb(); src = 'clean', runtime evaluates the raw default, but both the direct-call pass and the final lambda pass treat x as clean/trusted, so PY-WL-107 is missed.

Useful? React with 👍 / 👎.

Comment on lines +250 to +251
keyword_values = [taint for name, taint in kw_taints.items() if name is not None]
scope[args.kwarg.arg] = keyword_values[0] if keyword_values else 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 Propagate expanded keyword taint into lambda kwargs

For direct calls that use **raw_kwargs, kw_taints stores the expansion under None, but this comprehension drops that entry when binding the lambda's **kw parameter. A case like raw_kwargs = {'x': read_raw(p)}; cb = lambda **kw: eval(kw['x']); cb(**raw_kwargs) therefore records kw as function_taint and misses the sink even though the raw dictionary is what the lambda receives.

Useful? React with 👍 / 👎.

Comment on lines +897 to +900
if isinstance(stmt.value, ast.Lambda):
lambda_bindings[target.id] = stmt.value
else:
lambda_bindings.pop(target.id, None)

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 Carry lambda bindings through name aliases

When a lambda is first stored in one local and then assigned to another, this else path removes the destination binding instead of copying the source binding. For inner = lambda: eval(src); cb = inner; cb(); src = 'clean', the direct call through cb is no longer resolved at call time, and the later final-state lambda pass sees only the cleaned src, producing the same false negative this change is trying to prevent.

Useful? React with 👍 / 👎.

Comment on lines +235 to +236
for param, taint in zip(positional_params, pos_taints, strict=False):
scope[param.arg] = 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 Mask outer lambda bindings for shadowing parameters

These parameter bindings only update the local taint scope; _resolve_call still consults the enclosing function's _CURRENT_LAMBDA_BINDINGS by name. If a lambda parameter shadows an outer lambda binding, e.g. outer = lambda: eval(src); cb = lambda outer: outer(); cb(lambda: eval('1')); src = 'clean', the analyzer resolves outer() to the captured outer lambda instead of the argument that is actually called, recording a spurious raw sink.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown
Collaborator Author

Verified and merging — with a corrected regression test (pushed as 1e76458).

The engine fix is real and valuable, but not for the reason stated. I reproduced all four cases against main (commit before this PR) and this branch:

case main branch
src = read_raw(p); cb = lambda: eval(src); cb(); src = 'clean' (the PR's advertised free-var case) fires fires
raw = read_raw(p); cb = lambda x: eval(x); cb(raw) (arg→param) silent FN (no fallback warning) fires
(lambda x: eval(x))(raw) (inline) silent FN fires
cb = lambda x: eval(x); cb('ls') (clean arg) no fire no fire

The advertised free-variable case is already covered by #20's worst-ever-taint pass — it fires on main without this change, so the PR's original test (test_107_lambda_called_before_later_clean_assignment_still_fires) guards nothing. What this change actually closes is a genuine silent false-negative: a raw value bound to a lambda parameter at a direct/inline call site. The worst-ever pass resets lambda params to neutral and structurally cannot see argument taint; only the new call-time resolution (_resolve_lambda_body_at_call) can. The conservative call_site_arg_taints merge is sound (least-trusted join over a worst-ever floor → cannot manufacture a finding) and introduces no false positives (clean-arg negative confirmed).

So I kept the variable_level.py engine change as-is and replaced the worthless test with three that genuinely exercise the fix (arg-binding fires, inline fires, clean-arg does not — the first two fail on main). Full suite green (2358 passed), ruff + mypy clean.

@tachyon-beep
tachyon-beep merged commit 7d01b47 into main Jun 5, 2026
4 of 6 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: 1e76458677

ℹ️ 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 +628 to +630
if isinstance(node.func, ast.Name) and lambda_bindings is not None and node.func.id in lambda_bindings:
_resolve_lambda_body_at_call(
lambda_bindings[node.func.id],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate stale lambda bindings before name calls

When a lambda-bound name is later rebound by a non-Assign target such as a for/with/match capture, _assign_target only updates var_taints and leaves _CURRENT_LAMBDA_BINDINGS pointing at the old lambda. For example, cb = lambda: eval(src); for cb in [lambda: eval('1')]: cb(); src = 'clean' calls the loop lambda at runtime, but this branch still resolves cb() against the earlier lambda with the raw src, producing a PY-WL-107 false positive that the final-state pass would not have emitted after the clean assignment. Rebindings outside Assign/AnnAssign need to clear or conservatively merge the lambda binding state before this lookup is trusted.

Useful? React with 👍 / 👎.

Comment on lines +235 to +238
for param, taint in zip(positional_params, pos_taints, strict=False):
scope[param.arg] = taint
for param in positional_params[len(pos_taints) :]:
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.

P2 Badge Conservatively spread starred args across lambda params

When a direct lambda call uses *args, this zip treats the whole expanded iterable as only one positional argument, so any later formal parameters fall through to function_taint. A case like args = read_raw(p); cb = lambda x, y: eval(y); cb(*args) receives raw data for y at runtime, but the call-time pass marks only x raw and the sink is missed. The taint from a starred positional expansion needs to be applied conservatively to all parameters it may fill, not consumed as a single slot.

Useful? React with 👍 / 👎.

Comment on lines +897 to +899
if isinstance(stmt.value, ast.Lambda):
lambda_bindings[target.id] = stmt.value
else:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Track lambda bindings assigned by destructuring

Restricting _CURRENT_LAMBDA_BINDINGS updates to simple Name = lambda assignments misses direct calls through lambdas bound by tuple/list unpacking. For example, src = read_raw(p); cb, _ = (lambda: eval(src), None); cb(); src = 'clean' executes the raw sink at the call site, but this branch never records cb as a lambda, and the later final-state pass sees only the cleaned src, so PY-WL-107 is silently missed. The binding update needs to cover destructured lambda RHS elements as well as simple assignments.

Useful? React with 👍 / 👎.

@tachyon-beep
tachyon-beep deleted the codex/fix-lambda-sink-taint-analysis-issue branch June 13, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant