Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(e2): exempt child-process env pass-through from harvesting - #492

Open
malinfossum wants to merge 2 commits into
NVIDIA:mainfrom
malinfossum:malinfossum/e2-child-process-env-passthrough
Open

malinfossum wants to merge 2 commits into
NVIDIA:mainfrom
malinfossum:malinfossum/e2-child-process-env-passthrough

Conversation

@malinfossum

Copy link
Copy Markdown

Summary

E2 no longer fires on an os.environ copy whose only destination is a child process's env=. An environ copy that goes anywhere else is unchanged, and so are E1, E3E5, the regex fallback for unparsable Python, and every non-Python path.

Fixes #441.

Root cause

_analyze_python_environment_reads keys on the copy rather than on where the copy goes, so subprocess.run(cmd, env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}) reached the same emit() 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 passing os.environ through 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 an env= argument is not a way to silence E2.

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:

before after
variants.py:8 env={**os.environ, ...} E2 HIGH conf 0.6 not flagged
variants.py:13 env = os.environ.copy() E2 HIGH conf 0.6 not flagged
harvester.py:7 dict(os.environ)requests.post E2 HIGH conf 0.6 E2 HIGH conf 0.6
score 69 54

The 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 against main: one per benign shape, plus a guard that an environ copy bound to a name and sent to requests.post still fires.

  • pytest tests/nodes/analyzers/test_static_patterns.py -k e2 — 7 passed
  • pytest -m "not integration and not provider" tests/ — 3954 passed, 26 skipped, 4 xfailed, 22 failed
  • ruff check src/ tests/ — clean
  • ruff format --check src/ tests/ — clean

The 22 failures are pre-existing on a Windows host and unrelated to this change: I ran the same four files on main with this branch stashed and got the identical 22 (test_build_context.py symlink and secure-open cases, test_compare_scan_accuracy.py — which is #485test_create_github_release.py, and one test_input_handler.py case). No test outside test_static_patterns.py changes state with this patch applied.

Scope

This fixes the E2 precision slice only. It does not touch E2's severity or confidence values, the #329 broadening that made the rule match {**os.environ, ...} in the first place, or E1/taint coverage of credential flows to network sinks.

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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 as env= anywhere in the file, rather than on the definition that reaches that child-process call. For example, an earlier env = os.environ.copy(); requests.post(..., json=env) is hidden if a later reassignment env = {} is passed to subprocess.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-process env argument, 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]>
@malinfossum
malinfossum force-pushed the malinfossum/e2-child-process-env-passthrough branch from 7990d72 to d2bdefe Compare September 14, 2026 08:13
@malinfossum

Copy link
Copy Markdown
Author

Both gaps confirmed and fixed in d2bdefe; branch rebased on current main.

The exemption no longer keys on names. _EnvironmentFlowVisitor walks the file in evaluation order (assignment values before their targets) and follows each name bound to a candidate expression until it is rebound. A copy is exempt only when every use up to that point is either a child-process env= argument or an in-place edit of the mapping (env[...] = ..., del env[...], env |= ..., update/pop/popitem/setdefault/clear). Any other use — posted, returned, unpacked into another value, read from a nested function — keeps the E2 finding. Multi-target assignments require every binding to be clean.

Regression tests added, both asserting the finding stays on line 4:

  • test_e2_environ_copy_rebound_before_subprocess_still_flaggedenv = os.environ.copy(); requests.post(json=env); env = {}; subprocess.run(env=env)
  • test_e2_environ_copy_exfiltrated_and_passed_through_still_flagged — same mapping posted and passed through
  • plus test_e2_environ_copy_edited_in_place_before_subprocess_not_flagged for the mutation allowlist

Ordering is by source position, so a rebinding in one branch of an if or a back-edge in a loop errs toward keeping the finding rather than hiding it.

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants