Skip to content

fix: detect namespace-hidden archive Python calls#1317

Open
mldangelo-oai wants to merge 7 commits into
mainfrom
mdangelo/codex/fix-archive-python-dict-call-detection
Open

fix: detect namespace-hidden archive Python calls#1317
mldangelo-oai wants to merge 7 commits into
mainfrom
mdangelo/codex/fix-archive-python-dict-call-detection

Conversation

@mldangelo-oai
Copy link
Copy Markdown
Contributor

@mldangelo-oai mldangelo-oai commented May 25, 2026

Summary

  • resolve statically known dangerous Python callables retrieved via module.__dict__, vars(module), getattr(module, '__dict__'), and module.__getattribute__(...) inside generic archive members
  • cover implicit unshadowed __builtins__ mappings, including retrieval through globals()['__builtins__'], using the existing builtins.* security policy names
  • carry namespace mapping aliases through bounded AST analysis and handle literal [], .get(), .__getitem__(), and .__call__() forms
  • add ZIP and TAR positive regressions plus benign and shadowed-helper negative coverage

Root cause

The shared ZIP/TAR Python-member analyzer already resolved direct calls, imported aliases, rebindings, and builtin getattr(...), but did not resolve equivalent callable retrieval through module namespace access. Reproduced examples that previously emitted no Python Archive Member Security finding include:

import os
os.__dict__['sys' + 'tem']('echo hidden')
__builtins__['ev' + 'al']('1 + 1')
globals()['__builtins__']['ev' + 'al']('1 + 1')

Security behavior

  • module dictionary retrieval now reports os.system under S101 and subprocess.run under S103
  • implicit builtins mapping retrieval now reports builtins.eval under S104
  • ordinary benign dictionary lookups and locally shadowed vars, __builtins__, and globals mappings remain unreported, limiting new false positives
  • dynamic runtime-generated names and context-dependent constructs remain outside this bounded static analysis

Validation

  • runtime reproduction before fix: namespace and implicit-builtins payloads produced no static calls or ZIP/TAR Python-member findings
  • runtime reproduction after fix: os.system and builtins.eval are resolved and reported; shadowed helper cases stay clean
  • focused archive/core/exit-code selection: 334 passed
  • uv --no-config run --locked ruff format modelaudit/ packages/modelaudit-picklescan/src packages/modelaudit-picklescan/tests tests/
  • uv --no-config run --locked ruff check --fix modelaudit/ packages/modelaudit-picklescan/src packages/modelaudit-picklescan/tests tests/
  • uv --no-config run --locked mypy modelaudit/ packages/modelaudit-picklescan/src packages/modelaudit-picklescan/tests tests/
  • PROMPTFOO_DISABLE_TELEMETRY=1 uv --no-config run --locked pytest -n auto -m "not slow and not integration" --maxfail=1 (4762 passed, 971 skipped)
  • npx prettier --check CHANGELOG.md

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 2026

Workflow run and artifacts

Performance Benchmarks

Compared 12 shared benchmarks with a regression threshold of 15%.
Status: 0 regressions, 0 improved, 12 stable, 0 new, 0 missing.
Aggregate shared-benchmark median: 677.95ms -> 691.13ms (+1.9%).

Workload Benchmark Target Size Files Baseline Current Change Status
suspicious-pickle-intake tests/benchmarks/test_scan_benchmarks.py::test_scan_suspicious_pickle_intake suspicious-intake 183.8 KiB 4 92.16ms 97.94ms +6.3% stable
chunked-upload-stream tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_chunked_upload_stream chunked_stream 278.2 KiB 1 18.05ms 19.18ms +6.2% stable
mixed-model-repository tests/benchmarks/test_scan_benchmarks.py::test_scan_release_candidate_repository release-candidate 547.3 KiB 32 266.43ms 273.27ms +2.6% stable
clean-training-checkpoint tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_clean_training_checkpoint safe_large 278.2 KiB 1 14.96ms 15.19ms +1.6% stable
nested-payload-review tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_nested_payload_review[nested_base64] nested_base64 98 B 1 480.8us 474.8us -1.3% stable
nested-payload-review tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_nested_payload_review[nested_hex] nested_hex 130 B 1 487.7us 493.1us +1.1% stable
nested-payload-review tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_nested_payload_review[nested_raw] nested_raw 78 B 1 475.1us 470.0us -1.1% stable
padded-multi-stream-upload tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_padded_multi_stream_upload multi_stream_padded 4.1 KiB 1 1.67ms 1.68ms +0.7% stable
duplicate-heavy-registry tests/benchmarks/test_scan_benchmarks.py::test_scan_duplicate_registry_snapshot registry-snapshot 915.2 KiB 13 189.49ms 188.75ms -0.4% stable
direct-malicious-upload tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_direct_malicious_upload malicious_reduce 52 B 1 1.59ms 1.59ms -0.3% stable
single-checkpoint-preflight tests/benchmarks/test_scan_benchmarks.py::test_scan_single_checkpoint_before_load single_checkpoint.pkl 183.0 KiB 1 35.69ms 35.61ms -0.2% stable
warm-cache-rescan tests/benchmarks/test_scan_benchmarks.py::test_scan_warm_cached_repository_rescan release-candidate 547.3 KiB 32 56.46ms 56.47ms +0.0% stable

@mldangelo-oai mldangelo-oai marked this pull request as ready for review May 25, 2026 03:24
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ab0f737d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread modelaudit/scanners/archive_member_security.py Outdated
Comment thread modelaudit/scanners/archive_member_security.py
Comment thread modelaudit/scanners/archive_member_security.py
Comment thread modelaudit/scanners/archive_member_security.py Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR extends the archive-member Python static analyzer to detect high-risk callables that are accessed indirectly through module namespace mappings (e.g., os.__dict__[...], vars(module)[...], getattr(module, '__dict__')[...], module.__getattribute__(...)) and via implicit __builtins__ mappings (including globals()['__builtins__']), so that ZIP/TAR member payloads can’t evade the existing high-risk call policy.

Changes:

  • Add namespace-mapping and implicit-builtins resolution to the bounded AST analysis used for generic ZIP/TAR Python members.
  • Add ZIP/TAR regression tests covering positive detections and shadowing/benign negatives.
  • Document the new detection behavior in the changelog.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
modelaudit/scanners/archive_member_security.py Expands AST name resolution to handle module __dict__ / vars() / globals()['__builtins__'] mapping lookups and __getattribute__ patterns.
tests/scanners/test_zip_scanner.py Adds ZIP-focused regression tests for namespace mapping and implicit builtins mapping detection (plus benign/shadowed negatives).
tests/scanners/test_tar_scanner.py Adds TAR regressions for namespace mapping and implicit builtins mapping detection.
CHANGELOG.md Notes the new detection of dangerous calls hidden via module namespace dictionaries.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread modelaudit/scanners/archive_member_security.py
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b18080fa0f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread modelaudit/scanners/archive_member_security.py Outdated
Comment thread modelaudit/scanners/archive_member_security.py Outdated
Comment thread modelaudit/scanners/archive_member_security.py
Comment thread modelaudit/scanners/archive_member_security.py
Comment thread modelaudit/scanners/archive_member_security.py Fixed
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef61d17d4f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

self._pop_alias_scope()

def visit_ListComp(self, node: ast.ListComp) -> None:
self._visit_comprehension(node.generators, [node.elt], inline_module_scope=True)
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 Treat comprehensions as nested for locals lookups

At module scope in Python 3, list/set/dict comprehensions run their element and filter expressions in an implicit nested scope, so locals()/zero-arg vars() there do not expose the module globals or __builtins__ (for example, [locals()['__builtins__']['eval']('1') for _ in [0]] raises KeyError before calling eval). Passing inline_module_scope=True here lets those calls resolve as module mappings and reports builtins.eval, creating false positives for archive members that cannot execute the flagged callable; generator expressions are already handled as nested scope, and list/set/dict comprehensions need the same treatment for locals()/vars() lookups.

Useful? React with 👍 / 👎.

if not generators:
return
self.visit(generators[0].iter)
self._push_alias_scope()
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 Preserve walrus bindings from comprehensions

In Python 3, assignment expressions inside comprehensions bind in the containing scope, but this new comprehension visitor always pushes a child alias scope and then discards it. As a result, payloads like import os; [runner := os.system for _ in (1,)]; runner('x') execute os.system but are resolved as an unknown runner call after the scope is popped, leaving a straightforward archive-member bypass for dangerous aliases created by list/set/dict comprehensions (and consumed generator expressions).

Useful? React with 👍 / 👎.

return namespace_aliases or None


_NAMESPACE_LOOKUP_METHODS = frozenset({"get", "__getitem__", "pop", "setdefault"})
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 Record setdefault namespace inserts

Adding setdefault as a namespace lookup catches the returned callable when it is invoked immediately, but setdefault also writes the default into the mapping when the key is absent. Because no alias binding is recorded for that side effect, import os; namespace = globals(); namespace.setdefault('runner', os.system); runner('x') executes os.system while the later runner lookup remains unresolved, so this bypasses the new globals/namespace write coverage.

Useful? React with 👍 / 👎.

if self._non_module_scope_depth == 0:
self._bind_name(key, resolved_value)
continue
self._bind_name(f"{root}.{key}", resolved_value)
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 Resolve attributes added through namespace writes

When a write targets a real module namespace such as os.__dict__, this records the dotted alias (os.runner) but direct attribute calls resolve aliases only through the head name (os), so the binding is never used for os.runner(...). A payload like import os; os.__dict__['runner'] = os.system; os.runner('x') executes os.system and is missed, even though the equivalent subscript read is covered by the new namespace-write tracking.

Useful? React with 👍 / 👎.

if isinstance(target, ast.Name):
self._bind_name(target.id, _resolve_static_reference_names(value, self.alias_scopes))
self._bind_name(target.id, self._resolve_reference_names(value))
elif isinstance(target, ast.Subscript):
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 setitem namespace writes

This new write tracking only handles subscript assignment targets, but the same namespace mutation can be expressed as a method call and then used through the global name. For example, import os; globals().__setitem__('runner', os.system); runner('x') executes os.system, but __setitem__ is visited only as a regular call so no alias is recorded and the later runner call is missed.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants