Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion src/wardline/scanner/taint/variable_level.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@
"_CURRENT_VAR_TYPES", default=None
)

_CURRENT_LAMBDA_BINDINGS: contextvars.ContextVar[dict[str, ast.Lambda] | None] = contextvars.ContextVar(
"_CURRENT_LAMBDA_BINDINGS", default=None
)

_CURRENT_MODULE_PREFIX: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"_CURRENT_MODULE_PREFIX", default=None
)
Expand Down Expand Up @@ -209,6 +213,47 @@ def _resolve_lambda_bodies(
_resolve_expr(lam.body, function_taint, taint_map, scope)


def _resolve_lambda_body_at_call(
lam: ast.Lambda,
function_taint: TaintState,
taint_map: dict[str, TaintState],
var_taints: dict[str, TaintState],
pos_taints: list[TaintState],
kw_taints: dict[str | None, TaintState],
) -> None:
"""Record a bound lambda body's call-site arg taints at a direct call site.

The final lambda-body pass is deliberately conservative for escaping/deferred
lambdas, but direct ``cb()`` calls have stronger ordering information: the
lambda executes with the current enclosing variable taints at this statement.
Recording the body here prevents a later clean assignment from laundering a
raw value that was captured when the direct call actually ran.
"""
scope = dict(var_taints)
args = lam.args
positional_params = [*args.posonlyargs, *args.args]
for param, taint in zip(positional_params, pos_taints, strict=False):
scope[param.arg] = taint
Comment on lines +235 to +236

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

for param in positional_params[len(pos_taints) :]:
scope[param.arg] = function_taint
Comment on lines +237 to +238

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 +235 to +238

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

if args.vararg is not None:
extra = pos_taints[len(positional_params) :]
scope[args.vararg.arg] = extra[0] if extra else function_taint
for taint in extra[1:]:
scope[args.vararg.arg] = combine(scope[args.vararg.arg], taint)
for param in args.kwonlyargs:
scope[param.arg] = kw_taints.get(param.arg, function_taint)
for param in positional_params:
if param.arg in kw_taints:
scope[param.arg] = combine(scope.get(param.arg, function_taint), kw_taints[param.arg])
if args.kwarg is not None:
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
Comment on lines +250 to +251

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

for taint in keyword_values[1:]:
scope[args.kwarg.arg] = combine(scope[args.kwarg.arg], taint)
_resolve_expr(lam.body, function_taint, taint_map, scope)


def compute_variable_taints(
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
function_taint: TaintState,
Expand Down Expand Up @@ -255,6 +300,7 @@ def compute_variable_taints(
if provenance_clash is not None:
token_clash = _PROVENANCE_CLASH.set(provenance_clash)
token_types = _CURRENT_VAR_TYPES.set({})
token_lambdas = _CURRENT_LAMBDA_BINDINGS.set({})
if alias_map is not None:
token = _CURRENT_ALIAS_MAP.set(alias_map)
if call_site_arg_taints is not None:
Expand All @@ -272,6 +318,7 @@ def compute_variable_taints(
if token_clash is not None:
_PROVENANCE_CLASH.reset(token_clash)
_CURRENT_VAR_TYPES.reset(token_types)
_CURRENT_LAMBDA_BINDINGS.reset(token_lambdas)
if token is not None:
_CURRENT_ALIAS_MAP.reset(token)
if token_args is not None:
Expand Down Expand Up @@ -554,9 +601,14 @@ def _resolve_call(
if isinstance(arg, ast.Starred):
resolved_args[f"*{i}"] = t
kw_taints = []
kw_arg_taints: dict[str | None, TaintState] = {}
for kw in node.keywords:
t = _resolve_expr(kw.value, function_taint, taint_map, var_taints)
kw_taints.append(t)
if kw.arg in kw_arg_taints:
kw_arg_taints[kw.arg] = combine(kw_arg_taints[kw.arg], t)
else:
kw_arg_taints[kw.arg] = t
if kw.arg in resolved_args:
resolved_args[kw.arg] = combine(resolved_args[kw.arg], t)
else:
Expand All @@ -565,7 +617,32 @@ def _resolve_call(

call_site_arg_taints = _CURRENT_CALL_SITE_ARG_TAINTS.get()
if call_site_arg_taints is not None:
call_site_arg_taints[id(node)] = resolved_args
existing = call_site_arg_taints.get(id(node))
if existing is None:
call_site_arg_taints[id(node)] = resolved_args
else:
for key, taint in resolved_args.items():
existing[key] = combine(existing[key], taint) if key in existing else taint

lambda_bindings = _CURRENT_LAMBDA_BINDINGS.get()
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],
Comment on lines +628 to +630

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 +628 to +630

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

function_taint,
taint_map,
var_taints,
pos_taints,
kw_arg_taints,
)
elif isinstance(node.func, ast.Lambda):
_resolve_lambda_body_at_call(
node.func,
function_taint,
taint_map,
var_taints,
pos_taints,
kw_arg_taints,
)

alias_map = _CURRENT_ALIAS_MAP.get()
imported_fqn: str | None = None
Expand Down Expand Up @@ -708,6 +785,13 @@ def _process_stmt(
if isinstance(stmt.target, ast.Name):
taint = _resolve_expr(value, function_taint, taint_map, var_taints)
var_taints[stmt.target.id] = taint

lambda_bindings = _CURRENT_LAMBDA_BINDINGS.get()
if lambda_bindings is not None:
if isinstance(value, ast.Lambda):
lambda_bindings[stmt.target.id] = value
else:
lambda_bindings.pop(stmt.target.id, None)
elif isinstance(stmt.target, (ast.Subscript, ast.Attribute)):
# Annotated container/attribute write (``self.x: str = expr``,
# ``d['k']: T = expr``) — same fail-open shape as the plain-Assign Part
Expand Down Expand Up @@ -808,6 +892,13 @@ def _handle_assign(
)
var_taints[target.id] = taint

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

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

lambda_bindings.pop(target.id, None)
Comment on lines +897 to +900

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


var_types = _CURRENT_VAR_TYPES.get()
if var_types is not None:
alias_map = _CURRENT_ALIAS_MAP.get()
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/scanner/rules/test_sink_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,64 @@ def f(p):
assert not any(str(w.message).startswith("WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK") for w in caught)


def test_107_lambda_called_with_raw_arg_fires(tmp_path) -> None:
# The load-bearing case this change fixes: a raw value is passed as a CALL
# ARGUMENT bound to the lambda's parameter, and the sink consumes the param.
# The worst-ever pass resets lambda params to neutral, so it cannot see the
# argument taint — only call-time resolution can. Silent false-negative on
# the prior engine (no fallback warning), so this test genuinely guards it.
ctx = _analyze(
tmp_path,
"""
@trusted(level='ASSURED')
def f(p):
raw = read_raw(p)
cb = lambda x: eval(x)
cb(raw)
""",
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
findings = UntrustedToExec().check(ctx)
assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-107", "m.f")]
assert not any(str(w.message).startswith("WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK") for w in caught)


def test_107_inline_lambda_call_with_raw_arg_fires(tmp_path) -> None:
# Same defect via an inline (un-named) lambda invoked immediately with a raw
# argument: ``(lambda x: eval(x))(raw)``. Also a silent FN on the prior engine.
ctx = _analyze(
tmp_path,
"""
@trusted(level='ASSURED')
def f(p):
raw = read_raw(p)
(lambda x: eval(x))(raw)
""",
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
findings = UntrustedToExec().check(ctx)
assert [(x.rule_id, x.qualname) for x in findings] == [("PY-WL-107", "m.f")]
assert not any(str(w.message).startswith("WLN-ENGINE-FLOW-INSENSITIVE-FALLBACK") for w in caught)


def test_107_lambda_called_with_clean_arg_does_not_fire(tmp_path) -> None:
# Negative guard: call-time argument binding must NOT manufacture a finding
# when the argument is a clean literal. Protects against the call-time path
# over-firing (false positive).
ctx = _analyze(
tmp_path,
"""
@trusted(level='ASSURED')
def f(p):
cb = lambda x: eval(x)
cb('ls -la')
""",
)
assert UntrustedToExec().check(ctx) == []


def test_107_raw_assigned_after_lambda_def_still_fires(tmp_path) -> None:
# Closure-by-reference soundness: the lambda is DEFINED while ``src`` is clean,
# but ``src`` is reassigned raw before the lambda is called — at runtime eval()
Expand Down
Loading