Skip to content

security: the network check could not see httpx2 - #8565

Merged
danielhanchen merged 3 commits into
mainfrom
studio/scanner-httpx2-network
Aug 12, 2026
Merged

danielhanchen merged 3 commits into
mainfrom
studio/scanner-httpx2-network

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

RE_NETWORK matched httpx. but not httpx2..

httpx2 is not a submodule of httpx, it is the pydantic-maintained successor under a separate import name. openai 3.0.0 requires httpx2<3,>=2.7.0 and routes every call through it, so the entire OpenAI SDK's HTTP surface was invisible to the scanner.

That matters because several checks are deliberately combined: they only fire when a file does something sensitive and makes a network call. With the network half unmatched, secrets + network, IMDS + network and archive + network could not fire on any package using httpx2.

The visible symptom was already in the logs. openai/auth/_workload.py reported as a standalone HIGH "accesses cloud metadata / IMDS endpoints" instead of the combined CRITICAL, because the scanner could not correlate the IMDS URLs with the httpx2.Client() calls immediately beside them:

url = "http://169.254.169.254/metadata/identity/oauth2/token"
...
with httpx2.Client() as client:
    response = client.get(url, params=params, headers={"Metadata": "true"}, timeout=timeout)

The change

-    r"|\bhttpx\s*\.\s*(get|post|put|patch|delete|Client|AsyncClient)\b"
+    r"|\b(?:httpx|httpx2)\s*\.\s*(get|post|put|patch|delete|Client|AsyncClient)\b"

Explicit alternation rather than httpx2? so the intent reads at a glance. It stays anchored: myhttpx.get, httpx23.get and httpx2.Timeout(5) do not match, and all three are asserted.

What it surfaces

Four findings, all in openai 3.0.0, all benign, all baselined here. The package archive was already confirmed byte-identical to the upstream openai/openai-python v3.0.0 tag when its earlier findings were reviewed, so this is a triage of new checks firing on known-good code, not a new package.

File Sensitive half Network half
openai/_client.py OPENAI_API_KEY, OPENAI_ADMIN_KEY, OPENAI_WEBHOOK_SECRET httpx2.URL / httpx2.Client annotations
openai/lib/azure.py AZURE_OPENAI_API_KEY, AZURE_OPENAI_AD_TOKEN httpx2.Client / AsyncClient annotations
openai/lib/bedrock.py AWS_BEARER_TOKEN_BEDROCK httpx2.URL annotations
openai/auth/_workload.py Azure IMDS + GCP metadata URLs real httpx2.Client().get(...) calls

Worth being precise about the first three: their network half is entirely type annotations, not calls. The combined check fires on a file that reads a credential and mentions httpx2.Client in a signature. That is the check working as designed on a coarse signal, not evidence of anything.

The fourth is the real one, and I read it rather than assuming. Both providers fetch a subject token from the instance metadata service and return it to the caller for exchange at token_exchange_url; the only network destinations in the file are the two metadata endpoints and that exchange. This is documented workload identity federation.

Verification

Baseline edit is purely additive: the 224 existing entries are byte-identical and in order, 4 appended, _comment and version untouched. Entries were produced by loading scan_packages.py and calling its own scan_archive and _finding_key, not by --write-baseline, which would have rewritten the whole file from one shard's run.

shard before after
extras 4 CRITICAL, exit 1 167 MEDIUM only, exit 0
hf-stack exit 0 exit 0
studio exit 0 in CI exit 0 in CI

audit-reqs/ was rebuilt exactly as the workflow's "Build filtered requirements set" step does and is byte-identical to the artifacts CI produced.

The studio shard exits 1 on my machine for a reason that cannot occur in CI: this host is Python 3.13 and CI pins 3.12, and multiprocess 0.70.19 ships a different wheel per minor version. I downloaded the py312 wheel and ran the scanner against it: all three of its CRITICAL findings are already baselined, so CI's shard has nothing non-baselined. I did not baseline the py313 variants, since CI never fetches that archive and adding them would be unreviewed suppression.

pytest tests/security/test_scan_packages.py: 113 passed, including the two added here. Those two were mutation-checked against the old regex, where all three httpx2 spellings go undetected, so they fail without the fix rather than passing vacuously.

Not covered

Widening this one pattern does not make the network check complete. Still unmatched, and each would need its own triage of whatever it surfaces:

aiohttp.ClientSession, urllib3.PoolManager / urllib3.request, websockets.connect, pycurl, grpc.insecure_channel, niquests, paramiko, ftplib, smtplib.

Of those, urllib3 and websockets are in the declared dependency set. urllib3 is partly covered in practice because its usual call shape is conn.urlopen(...), which the urlopen\s*\( alternative already matches. I have not measured the others, so I am not claiming they are or are not a problem, only that they are not in scope here.

httpx2 is the pydantic-maintained successor to httpx and a separate import name, so
the httpx-only alternative in RE_NETWORK did not match it. openai 3.0.0 requires
httpx2 and routes every call through it, which made the SDK's own HTTP invisible to
every combined check that needs a network half: secrets plus network, IMDS plus
network, archive plus network.

The visible symptom was openai/auth/_workload.py reporting as a standalone HIGH
"accesses cloud metadata" rather than the combined CRITICAL, because the scanner
could not correlate the IMDS URLs with the httpx2.Client() calls beside them.

Widening it surfaces four findings, all in openai, all reviewed and benign, all
baselined here. Two tests pin the behaviour so the widening cannot be reverted
silently.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@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: 31f1e5dfe4

ℹ️ 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 scripts/scan_packages_baseline.json Outdated
Comment on lines +1803 to +1804
"evidence": "Env: L210: api_key = os.environ.get(\"OPENAI_API_KEY\") | L220: admin_api_key = os.environ.get(\"OPENAI_ADMIN_KEY\") | L244: webhook_secret = os.environ.get(\"OPENAI_WEBHOOK_SECRET\") | L816: api_key = os.environ.get(\"OPENAI_API_KEY\") | L826: admin_api_key = os.environ.get(\"OPENAI_ADMIN_KEY\") | L850: webhook_secret = os.environ.get(\"OPENAI_WEBHOOK_SECRET\")\nNetwork: L147: http_client: httpx2.Client | None = None, | L599: http_client: httpx2.Client | None = None, | L753: http_client: httpx2.AsyncClient | None = None, | L1216: http_client: httpx2.AsyncClient | None = None,",
"evidence_hash": "ee8e7ed79d2bfe99f85f53b1bff3d0f59cfc0de3393ab31a2955ef4365005e3c"

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 Don't baseline type annotations as network evidence

If a later or compromised openai artifact preserves these environment reads and annotations but adds an instance request such as client.post(..., data=api_key), the CRITICAL finding retains the same _finding_key and is suppressed by this entry: RE_NETWORK matches httpx2.Client in the annotations but does not match calls through client, so the malicious request contributes no evidence. The Azure and Bedrock entries have the same problem. Baseline only findings backed by executable request evidence, extend the evidence to bind instance requests, or pin these annotation-only suppressions to the reviewed file digest.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, and it was the right thing to stop me on. I reproduced it before fixing it.

Taking the exact shape described, a file that reads a credential and carries httpx2.Client annotations, then appending client.post(..., data=api_key):

benign                   evidence_hash=8c81a79123c7d3bf
with client.post exfil   evidence_hash=8c81a79123c7d3bf

Byte-identical. RE_NETWORK does not match a call through an instance, so the payload contributes no evidence, the key is unchanged, and my entry would have gone on suppressing it. That is the opposite of what _finding_key promises in its own docstring.

Fixed by pinning the reviewed file digest, which is the mechanism _load_baseline already documents for this shape ("for files whose danger sits outside the matched lines"). All four entries are pinned, not just the three annotation-only ones: _workload.py has real httpx2.Client() calls, but an added client.post would leave its evidence unchanged too, so it has the same hole.

Verified both directions against the reviewed wheel:

reviewed artifact:                    tampered (client.post appended):
  suppressed  openai/_client.py         REOPENS  openai/_client.py
  suppressed  openai/auth/_workload.py  REOPENS  openai/auth/_workload.py
  suppressed  openai/lib/azure.py       REOPENS  openai/lib/azure.py
  suppressed  openai/lib/bedrock.py     REOPENS  openai/lib/bedrock.py

Chasing this turned up the same defect already in the file, which I would not have looked for otherwise. There were older unpinned entries for these four paths from when openai used bare httpx., carrying the identical annotation-only evidence. They are inert against openai 3.0.0, but they would suppress on any resolve that goes back to an httpx-based version, payload included. I cannot pin them, because they describe an artifact I have not reviewed, so I dropped them: five entries, four superseded CRITICALs plus the _workload.py HIGH that the combined CRITICAL now supersedes. If that evidence ever recurs the finding reopens and gets a fresh review, which is the behaviour I want.

Net: 224 -> 223 entries, 4 added and digest-pinned, 5 stale removed. A test pins the property so the digest pins cannot be dropped silently; it fails against the unpinned baseline rather than passing vacuously.

RE_NETWORK matches httpx2.Client where it appears in a signature, but not a call
through an instance. So a client.post(..., data=api_key) appended to one of these
files contributes no evidence: the evidence hash is unchanged and the entry keeps
suppressing it. Reproduced, the two hashes are byte-identical.

All four entries now pin the reviewed digest, the mechanism _load_baseline already
documents for files whose danger sits outside the matched lines. _workload.py is
pinned too: it has real httpx2.Client() calls, but an added client.post would leave
its evidence unchanged just the same.

Five older entries for these paths are dropped. They were baselined when openai used
bare httpx and carry the identical annotation-only evidence, inert against 3.0.0 but
suppressing on any resolve back to an httpx-based version. They cannot be pinned to
an artifact that was never reviewed, so they go and reopen if that evidence recurs.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@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

"evidence": "FS: L534: history: list[Response] | None = None, sha256:934ed9b3eb1a374f2ff77800fa760232842538d1d487e87e1411d764314bf086\nNetwork: L9: import urllib.request | L1228: class _CookieCompatRequest(urllib.request.Request):",
"evidence_hash": "c1faa319a4a8b53e133819b90be51fb12aae77477ae5ceda22d0f9930e646130"

P1 Badge Pin the httpx2 baseline entry to its reviewed digest

When a compromised httpx2/_models.py preserves the existing history ... read and urllib.request evidence but appends an instance request such as client.post(..., data=secret), this unpinned entry still suppresses the CRITICAL finding because RE_NETWORK does not match calls through client. I reproduced this against the cached httpx2/_models.py: the appended request changed the file digest but left _finding_key unchanged. Add the reviewed file_sha256 here, as done for the new OpenAI entries, so edits outside the matched evidence reopen the finding.

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

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

Following the P1 above further than it was raised, because the same shape is already in the file beyond the entries this PR touches.

I audited every baseline entry whose evidence carries a Network: half and asked whether that half contains an actual request rather than an import or a type annotation. 53 entries have a network half; 16 have one that no added call would change, and 13 of those are unpinned:

package file check
unpinned botocore botocore/utils.py IMDS + network
unpinned botocore botocore/utils.py secrets + network
unpinned botocore botocore/utils.py credential paths + network
unpinned urllib3 urllib3/util/ssl_.py secrets + network
unpinned urllib3 urllib3/response.py filesystem + network
unpinned fastmcp-slim fastmcp/server/auth/providers/jwt.py embedded key + network
unpinned httpx httpx/_models.py filesystem + network
unpinned httpx2 httpx2/_models.py filesystem + network
unpinned torch torch/cuda/_memory_viz.py filesystem + network
unpinned numpy numpy/lib/tests/test__datasource.py filesystem + network
unpinned pandas pandas/tests/io/test_pickle.py archive + network
unpinned pygments pygments/lexers/_php_builtins.py archive + network
unpinned unsloth-zoo tests/security/fixtures/_build.py archive + network

The three botocore/utils.py rows are the ones I would look at first, and they are worse than the entries this PR fixes. That file is the IMDS and credential resolver, and its entire network half is one line:

Network: L32: from urllib.request import getproxies, proxy_bypass

An import. A session.post(...) added anywhere in that file contributes no new match, so the evidence hash holds and all three entries keep suppressing.

I have deliberately not fixed these here. Pinning 13 entries across 10 packages means every bump of botocore, torch, urllib3 or numpy reopens a finding and needs re-triage, which is a policy call about maintenance cost rather than a bug fix, and it does not belong in a PR whose subject is one regex alternative. This PR stays scoped to the four entries it introduces.

Recording it so the audit is not lost. Happy to do it as a follow-up.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: baee91e6cd

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

@danielhanchen
danielhanchen merged commit 4a53e80 into main Aug 12, 2026
19 of 23 checks passed
@danielhanchen
danielhanchen deleted the studio/scanner-httpx2-network branch August 12, 2026 13:50
danielhanchen added a commit that referenced this pull request Aug 18, 2026
…ies (#9148)

* Security audit: pin openai, and re-review the four digest-pinned entries

The extras shard of the security audit has been red on main since 2026-08-17
19:14Z with four non-baselined CRITICALs, all in `openai`:

    Harvests environment variables/secrets AND makes network calls
      openai/_client.py, openai/lib/azure.py, openai/lib/bedrock.py
    Accesses cloud metadata/IMDS AND makes network calls
      openai/auth/_workload.py

It is not a code change here. The last green run was 18:23Z and every run after
18:40Z failed on the same four findings, on main and on unrelated PR branches
alike, which is upstream drift rather than anything in the tree.

`openai>=2.7.2` is the only floating spec in extras.txt that carries digest-pinned
baseline entries. openai 3.2.0 published at 19:14Z and changed all four files.
Confirmed by digest: the four `file_sha256` values in the baseline match openai
3.0.0 and 3.1.0 exactly and none of 3.2.0's.

The pin is deliberate and correct (#8104, #8565): the evidence for these entries
records that a network call exists but not where it goes, so `client.post(...,
data=api_key)` appended to one of these files would leave the evidence hash
untouched. Only the file digest can reopen that, so any edit upstream makes must
red the gate until someone re-reads it.

Which is the review, done here. Every destination in the four files at 3.2.0 is
first-party or a documented cloud metadata endpoint: api.openai.com,
auth.openai.com, 169.254.169.254 (Azure IMDS), metadata.google.internal,
management.azure.com, and bedrock-mantle.{region}.api.aws. The one instance-level
POST is the workload-identity token exchange, which defaults to
https://auth.openai.com/oauth/token and sets follow_redirects=False. The env reads
are OPENAI_API_KEY, OPENAI_ADMIN_KEY, OPENAI_WEBHOOK_SECRET,
AZURE_OPENAI_API_KEY, AZURE_OPENAI_AD_TOKEN and AWS_BEARER_TOKEN_BEDROCK, each
used to authenticate to its own service. Same benign patterns as the reviewed
3.0.0, so the four entries are re-pinned to 3.2.0's digests and nothing else in
the 214-entry baseline is touched.

Re-baselining alone would only buy time until the next release, so the spec is
pinned too. Every other requirement in extras.txt is already an exact pin (#8408
pinned the bare ones and skipped this one because it had a specifier). Pinning
makes the bump deliberate: whoever raises the version is the one who re-runs
--write-baseline and re-reads the diff, instead of the gate going red on release
day for a change nobody here made.

The new test asserts that invariant directly, since it is the part that will be
got wrong again: a package with digest-pinned baseline entries must not float in
studio/backend/requirements. Reverting the spec to `openai>=2.7.2` fails it and
names extras.txt:62.

Verified:
  - `scan_packages.py 'openai==3.2.0'` exits 0; on 3.1.0 it exits 1, so the pin
    still bites and the entries were not widened.
  - The whole extras shard (`--with-deps -r extras.txt`, 128 archives) exits 0
    with 0 active CRITICAL/HIGH, against 4 CRITICAL and exit 1 before.
  - tests/security/test_scan_packages.py: 121 passed.

Unrelated, and not a defect: the `openai-whisper==20250625` line in the same log
is an INFO, not the failure. That version is on PyPI as an sdist only, and the
bulk resolve runs `--only-binary :all:` so it never executes a setup.py, which is
the scanner's whole security model. "from versions: none" is what pip says about
an sdist-only package under that flag, and the per-spec fallback plus the direct
sdist fetch that follow are the designed path working. The scipy
`assert_raises(ValueError, CubicSpline, ...)` evidence quoted alongside it is a
MEDIUM and cannot fail the gate; it is a false positive on `bc1`, a boundary
condition variable in scipy's own test file that looks like a bech32 address.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep the openai pin off Python 3.9, which the first version of it broke

`openai==3.2.0` on its own was a regression, caught simulating the install rather
than reading it. openai 3.x is `requires-python >=3.10` and this project is
`>=3.9,<3.15`, so an exact pin resolves to nothing at all on 3.9, where `>=2.7.2`
had quietly been selecting 2.48.0. Fixing a CI gate by breaking an install is not
a fix.

Split on the marker instead, which is what the rest of this file already does for
soundfile and tabulate. 2.48.0 is the newest release that accepts 3.9, so the 3.9
branch pins exactly what 3.9 was resolving to before.

Resolved with uv against every interpreter in requires-python, --no-deps, three
ways (main, the bad pin, this):

  spec           3.9        3.10     3.12     3.13     3.14
  >=2.7.2        2.48.0     3.2.0    3.2.0    3.2.0    3.2.0
  ==3.2.0        NO SOLUTION  3.2.0  3.2.0    3.2.0    3.2.0
  split          2.48.0     3.2.0    3.2.0    3.2.0    3.2.0

So this is behaviour-preserving: identical to main on every supported Python, and
the whole of extras.txt resolves to the same 31 packages on 3.10, 3.12, 3.13 and
3.14 before and after, differing only in the `# via -r` filename. 3.9 fails on
both sides for an unrelated and pre-existing reason (scikit-learn==1.7.1 is itself
>=3.10), so nothing here changes that either.

The security audit is unaffected: it runs on 3.12, markers are evaluated by pip,
and the scan of the two-line spec fetches only 3.2.0 and exits 0. The 3.9 branch
is never scanned, exactly as the floating spec's 3.9 resolution never was.

The guard is widened to the second half of the same invariant: the exact pins for
a digest-pinned package must cover every supported Python between them, so a
marker partition with a hole in it fails. Its limit is now written down rather
than implied. It is marker-only and offline, so it catches a `>= "3.11"` beside a
`< "3.10"`, but it cannot catch a single unmarked pin whose release does not
support 3.9, because that means asking PyPI for the release's requires-python.
The resolution simulation above is what covers that case, and the test module
stays network-free.

Verified: reverting to `openai>=2.7.2` still fails the guard naming extras.txt.
tests/security/test_scan_packages.py: 121 passed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Widen the pin guard to the pyproject inputs, and make `==` mean one version

Two review findings, both real, both reproduced before fixing.

The guard read `studio/backend/requirements/*.txt` and stopped there, but
security-audit.yml builds `audit-reqs/unsloth-deps.txt` out of pyproject's
`project.dependencies` plus the `huggingfacenotorch` extra and feeds that to the
hf-stack shard. A digest-pinned package declared there was invisible to the guard,
which is the whole failure mode it exists to catch, one file over. It now reads
both halves, from one helper that mirrors the workflow's own input transform.

Reading pyproject turns up the counterexample the finding named: unsloth_zoo is
digest-pinned (hf_xet_health.py, the credential send that motivated #8104) and
declared `unsloth_zoo>=2026.8.12`. That one is deliberately not version-pinned and
must not be. The recurrence being prevented is an upstream release WE DO NOT
CONTROL changing the bytes and reddening main on a day nobody touched the repo;
unsloth_zoo is ours, released in lockstep, and an exact pin would break that. When
its digest reopens, the change is one of ours and re-reviewing it is the point of
the pin. So it is named in an exemption rather than quietly skipped, and the
exemption is itself asserted: it must contain only names that really are digest
pinned, and at least one third-party package must remain, so the guard cannot be
defused by growing the list. That assertion earned its keep immediately, failing
on `unsloth` until it was removed for having no pinned entry.

Second, `==` has to mean one version. The old prefix regex accepted `openai==3.*`,
which pip resolves as a prefix match to whatever 3.x is newest, recreating the
exact release-day failure. The specifier is now parsed with
packaging.requirements rather than pattern-matched: exactly one clause, operator
`==`, version not ending in `.*`. Parsing also drops the hand-rolled name and
marker splitting, so extras and whitespace stop being edge cases.

Four negative controls, each red with its own message and green after:
  - `openai>=2.7.2` in extras.txt: not pinned to one version, names extras.txt:68.
  - `openai==3.*`: same assertion, names the wildcard spec.
  - `< "3.9"` instead of `< "3.10"`: openai uncovered on 3.9.
  - `openai>=2.7.2` added to pyproject's dependencies: names pyproject.toml, which
    is the finding above and was green before this commit.

tests/security/test_scan_packages.py: 121 passed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Read pyproject through tomli when tomllib is not there

pyproject sets requires-python >=3.9 and testpaths ["tests/security"], so a
bare pytest from the repo root collects this module on 3.9 and 3.10, where
tomllib does not exist (it landed in 3.11, PEP 680). The two helpers added
here imported it unguarded, so on those interpreters the file raised
ModuleNotFoundError instead of reporting a result. tomli is already pinned for
python_version < 3.11 in extras-no-deps.txt, and four other suites in this repo
already use exactly this fallback.

The regression test supplies the backport rather than requiring it. The
tests-security job installs only pytest and PyYAML, so a test that leaned on a
real tomli being importable would importorskip its way to green there and never
execute the branch at all. Registering the parser this interpreter does have
under the name the fallback looks for keeps it load-bearing everywhere, while
import tomllib is made to fail for the duration so the fallback is provably
what gets consulted. Checked red on the unfixed helpers and green on the fixed
ones, including with tomli hidden.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
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.

1 participant