fix(e2): exempt child-process env pass-through from harvesting - #492
malinfossum wants to merge 2 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.
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]>
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.
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.