feat(detection): add SSRF (CWE-918) rule for Python (#86) - #89
Conversation
bkd-dotcom
left a comment
There was a problem hiding this comment.
Thanks for picking this up, and sorry for the slow turnaround.
I spent a while with this one because the diff is doing more than the title suggests. Short version: nothing here is unsafe — no new dependencies, no workflow/packaging changes, no subprocess/network/eval, and the regexes are all bounded alternations with no catastrophic-backtracking risk. CLA and CodeQL are fine. I have no concerns about running this code.
But I can't squash this as-is, for one substantive reason plus some cleanup. Details are inline; summarising the two that matter:
1. Precision regression (blocker). Adding _PY to _SPECS turns the line-based regex taint engine on for .py, which already gets the full AST pass in _PyVisitor. Because the regex layer only sees "tainted identifier and requests.get( on the same line", it fires HIGH-severity SSRF on constant-URL calls. I checked these against main — all four are clean before this PR and flagged after:
requests.get('https://api.example.com/search', params={'q': user_q}) # py.taint.ssrf
requests.post('https://api.example.com/v1/items', json=user_body) # py.taint.ssrf
requests.post('https://api.example.com/submit', data=user_form) # py.taint.ssrf
requests.get('https://api.example.com/me', headers={'Authorization': tok}) # py.taint.ssrfThe URL is hardcoded in every one. That's the single most common shape of HTTP client code in Python, so this would fire constantly on real repos — and precision is the whole pitch of this engine. The test suite's own docstring claims "ZERO FALSE POSITIVES: safe equivalents produce no findings"; CI is green only because nothing covers these shapes.
2. The new tests don't test the new code. I checked out just your tests/test_findings_engine.py onto unmodified main and both new tests pass. urllib.request.urlopen and httpx.get were already in the target tuple before this PR, so neither test reaches any of the ~45 new lines. The kwarg extraction, the .request positional handling, and the entire _PY spec are uncovered.
Worth flagging that this PR expands an existing rule rather than adding one — py.ssrf and test_detects_ssrf are already on main. Not a problem, but the title and #86 read as though SSRF were missing, and that framing is what made the 46 deletions surprising to me on first read.
What I'd need to merge:
- Drop
_PYfrom_SPECS. Python has an AST pass; the regex layer costs precision without adding recall here. That alone clears the blocker. - Fix the URL extraction to check keywords in addition to positionals (see inline) — right now
requests.request('GET', url=tainted)is missed by the AST rule. - Either make the aiohttp/httpx client-instance patterns actually match, or drop the dead entries.
- Tests that fail on
main. That's the bar for any detection change here. - Please split the unrelated refactors and the
py.flask_debugmessage rewrite into their own PR — happy to merge that separately and quickly.
The failing review check is not your fault — it's 403 Resource not accessible by integration; GITHUB_TOKEN is read-only for fork PRs so the advisory reviewer can't post. Ignore it. You will need to rebase on main though, the branch is behind.
Genuinely useful direction and the AST-side instincts are right — it's the regex layer and the test coverage that need another pass.
…d positional arguments Signed-off-by: AdvaitVarhade <199199199+AdvaitVarhade@users.noreply.github.com>
6dc036a to
00d711b
Compare
|
thank you for the detailed feedback. i have rebased on main and addressed all the review points:
please check and tell if any changes are required |
bkd-dotcom
left a comment
There was a problem hiding this comment.
Re-reviewed from scratch against the current head. This is a clean turnaround — you addressed every point, not just the easy subset. Thank you for taking the precision argument seriously rather than arguing it.
Verified rather than read:
The blocker is genuinely gone
lang_taint.py is untouched — gh pr diff --name-only returns only deterministic.py and the tests. The regex layer stays off for .py, and the AST pass remains the single source of truth for Python.
All the constant-URL cases I posted are silent again:
| case | main | this PR |
|---|---|---|
requests.get('https://…', params={'q': user_q}) |
clean | clean ✅ |
requests.post('https://…', json=user_body) |
clean | clean ✅ |
requests.post('https://…', data=user_form) |
clean | clean ✅ |
requests.get('https://…', headers={'Authorization': tok}) |
clean | clean ✅ |
requests.request('GET','https://…', params={'q': user_q}) |
clean | clean ✅ |
And it's a real detection win
Five shapes main misses that this catches:
| case | main | this PR |
|---|---|---|
requests.get(url=tainted) |
miss | caught |
requests.request('GET', tainted) |
miss | caught |
requests.request('GET', url=tainted) |
miss | caught |
httpx.patch(tainted) |
miss | caught |
aiohttp.request('GET', tainted) |
miss | caught |
The requests.request fix is the important one — main was checking args[0], which is the method, so requests.request was effectively dead for positional calls. Resolving keyword and positional URL arguments independently is the right shape for this.
Also confirmed fixed: # noqa: C901 - dispatch table by design restored; the Python-2 urllib.urlopen spelling gone; the py.flask_debug description/remediation rewrite reverted; dead targets removed (I checked every entry in ssrf_targets resolves to a real callable). 252 tests pass, ruff clean, rebased to zero commits behind main.
Merging this
One nit I'm handling myself rather than making you push again.
urllib.request.Request in ssrf_targets adds no detection and one duplicate. Taint already propagates through the assignment, so main catches that flow at the urlopen sink:
d = flask.request.args.get('d')
req = urllib.request.Request(d) # <- this PR flags here too
urllib.request.urlopen(req).read() # <- main already flags here
main: 1 finding (L5, at the sink)
this PR: 2 findings (L4 Request + L5 urlopen) <- same vuln, reported twice
Reporting the sink once is the behaviour we want, so I'll drop that single entry in a follow-up. Everything else lands exactly as you wrote it.
Credited in CONTRIBUTORS.md. That's your second solid rule for the engine — the Python SSRF coverage is materially better than what was there before.
Separately: I've left a detailed review on #91. Same verdict on safety (nothing unsafe), but it has a structural issue — the deserialisation rule already exists in main, so that PR doubles every finding. Details and a tested patch are over there.
Signetry Reviewer — 🟡 Needs human reviewA human should decide — the required check is pending. Deterministic gates (the authority)
FindingsNo issues found by the deterministic scanners. MergeA human should review and merge.
|
|
Merged — thanks @AdvaitVarhade. Credited in For the record, what landed:
I've opened #94 for the one nit — dropping Two process notes, both my fault rather than yours:
On #91: the review there is substantive but it's a structural issue, not a quality one — three of your four additions ( |
Follow-up to #89, as flagged in review. urllib.request.Request() only *builds* a request; taint propagates through `req = Request(tainted)`, so urlopen(req) already flags the flow. Listing the constructor as an SSRF sink as well reported one vulnerability twice, on adjacent lines — and because dedup keys on (file, line, category), two different lines cannot be collapsed. dest = flask.request.args.get('dest') req = urllib.request.Request(dest) # was flagged (L4) urllib.request.urlopen(req).read() # already flagged (L5) Dropping the constructor loses no detection: the sink still catches it. Verified — the case the contributor added in #89 still passes, and every other SSRF shape is unchanged (get/post positional + keyword, requests. request positional + keyword, httpx.patch, aiohttp.request, direct urlopen). Also credits @AdvaitVarhade in CONTRIBUTORS.md for #89. Co-authored-by: Binay <bkd-dotcom@users.noreply.github.com>
Summary
Adds detection for Server-Side Request Forgery (SSRF, CWE-918) in Python source code across both AST deterministic scanning and line-based language taint analysis.
Changes
signetry_core/pipeline/findings/deterministic.py:requests,urllib.request,httpx, andaiohttpoutbound request calls with tainted URL inputs (positional or keyword arguments).py.ssrf, categoryssrf, severityHIGH,CWE-918.signetry_core/pipeline/findings/lang_taint.py:_PYLangTaintSpecwith source tracking for request params/args/body and sink definitionpy.taint.ssrftargeting HTTP client calls.tests/test_findings_engine.py:requests,urllib.request, andhttpxcalls.please check and tell if any changes are required