From 0e7191b81edc17cb440c5e8c9058e4ac41fee465 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:04:31 +0900 Subject: [PATCH 001/105] test(ssrf): preserve bearer DNS TOCTOU vulnerable oracle --- ...pguardrail_bearer_dns_toctou_vulnerable.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_vulnerable.py diff --git a/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_vulnerable.py b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_vulnerable.py new file mode 100644 index 00000000..90ac4119 --- /dev/null +++ b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_vulnerable.py @@ -0,0 +1,20 @@ +"""Historical AppGuardrail bearer push shape vulnerable to DNS rebinding TOCTOU.""" + + +def push_scan(url, payload, api_key): + """Validate once, then let urllib resolve again while carrying a bearer token.""" + if not _is_safe_url(url): + return None + + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + method="POST", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + ) + opener = urllib.request.build_opener(SafeRedirectHandler()) + return opener.open(req, timeout=15) From 0530f8a6bbdbfe6893d2727f6546fec74d0afbb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:04:40 +0900 Subject: [PATCH 002/105] test(ssrf): preserve pinned HTTPS fixed oracle --- .../appguardrail_bearer_dns_toctou_fixed.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_fixed.py diff --git a/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_fixed.py b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_fixed.py new file mode 100644 index 00000000..480acbad --- /dev/null +++ b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_fixed.py @@ -0,0 +1,15 @@ +"""Reviewed AppGuardrail repair for bearer-authenticated DNS rebinding.""" + + +def push_scan(url, payload, api_key): + """Dispatch through the transport that pins the validated address set.""" + endpoint = url.rstrip("/") + "/api/v1/scans" + return post_json_pinned_https( + endpoint, + payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + timeout=15, + ) From ba480e252faa28d65195e2ff976263dc790e6599 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:05:15 +0900 Subject: [PATCH 003/105] feat(ssrf): detect bearer DNS validation TOCTOU --- scanner/rules/ssrf.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index b0625106..1627d7fd 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -12,3 +12,19 @@ rules: prefilter: [set_webhook] cwe: [CWE-918] owasp: [A10:2021] + - id: python-bearer-preflight-dns-toctou + patterns: + - pattern-regex: '(?imsx)^[ \t]*(?:async\s+)?def\s+[A-Za-z_][A-Za-z0-9_]*\s*\([^\n]*\)\s*:\s*\n(?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,6000}?if\s+not\s+_is_safe_url\s*\(\s*(?P[A-Za-z_][A-Za-z0-9_]*)\s*\)\s*:(?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,3000}?(?P[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P=url)\.rstrip\s*\([^\n]*\)[^\n]*(?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,3000}?(?P[A-Za-z_][A-Za-z0-9_]*)\s*=\s*urllib\.request\.Request\s*\(\s*(?P=endpoint)\b(?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,2000}?["\x27]Authorization["\x27]\s*:\s*f?["\x27]Bearer\s+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27](?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,3000}?(?:urllib\.request\.urlopen\s*\(\s*(?P=req)\b|[A-Za-z_][A-Za-z0-9_]*\.open\s*\(\s*(?P=req)\b)' + message: | + A bearer-authenticated urllib request is sent after a separate URL-safety + preflight. The network client can resolve the hostname again after validation, + creating a DNS-rebinding TOCTOU path for credential-bearing traffic. Connect + through the already-validated address set, or enforce an equivalent egress + boundary while preserving TLS hostname verification. + [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] + [CWE-918 - Server-Side Request Forgery] + severity: HIGH + languages: [python] + prefilter: [_is_safe_url, urllib.request.Request, Authorization, Bearer] + cwe: [CWE-367, CWE-918] + owasp: [A10:2021] From 785119a84eb1beb8f5da814cb49b1ec6567bf5c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:05:39 +0900 Subject: [PATCH 004/105] test(ssrf): exercise bearer DNS TOCTOU detector --- tests/test_bearer_dns_toctou_rule.py | 112 +++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_rule.py diff --git a/tests/test_bearer_dns_toctou_rule.py b/tests/test_bearer_dns_toctou_rule.py new file mode 100644 index 00000000..383c3678 --- /dev/null +++ b/tests/test_bearer_dns_toctou_rule.py @@ -0,0 +1,112 @@ +"""Regression tests for bearer-authenticated DNS validation TOCTOU detection.""" + +from pathlib import Path + +from scanner.cli.appguardrail import SCAN_RULES, _scan_file + +_RULE_ID = "python-bearer-preflight-dns-toctou" +_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "security_corpus" + + +def _rule(): + """Return the packaged DNS validation TOCTOU rule.""" + matches = [rule for rule in SCAN_RULES if rule["id"] == _RULE_ID] + assert len(matches) == 1, f"expected one loaded rule for {_RULE_ID}" + return matches[0] + + +def _fixture(name: str) -> str: + """Load one immutable source-backed regression fixture.""" + return (_FIXTURE_DIR / name).read_text(encoding="utf-8") + + +def _scan_source(tmp_path, source: str): + """Run the production file scanner and isolate this detector.""" + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] == _RULE_ID + ] + + +def test_rule_declares_high_ssrf_toctou_contract(): + """Keep stable severity and source prefilters for the bounded detector.""" + rule = _rule() + assert rule["severity"] == "HIGH" + assert rule["required_substrings"] == ( + "_is_safe_url", + "urllib.request.Request", + "Authorization", + "Bearer", + ) + + +def test_historical_bearer_preflight_flow_is_detected(tmp_path): + """Preserve the preflight-check then second-resolution flow as a positive.""" + findings = _scan_source( + tmp_path, + _fixture("appguardrail_bearer_dns_toctou_vulnerable.py"), + ) + assert len(findings) == 1 + finding = findings[0] + assert finding["severity"] == "HIGH" + assert "CWE-367" in finding["cwe"] + assert "CWE-918" in finding["cwe"] + + +def test_reviewed_pinned_https_repair_is_not_flagged(tmp_path): + """The protected transport connects to the validated address set itself.""" + assert not _scan_source( + tmp_path, + _fixture("appguardrail_bearer_dns_toctou_fixed.py"), + ) + + +def test_unauthenticated_urllib_delivery_is_not_flagged(tmp_path): + """Do not promote generic URL preflight use to this credential-bearing class.""" + source = """\ +def deliver(url, payload): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/hook" + req = urllib.request.Request(endpoint, data=payload) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_validation_without_network_dispatch_is_not_flagged(tmp_path): + """A preflight alone has no second-resolution credential exfiltration path.""" + source = """\ +def normalize(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return req +""" + assert not _scan_source(tmp_path, source) + + +def test_sibling_functions_cannot_donate_dispatch_evidence(tmp_path): + """Keep the matched preflight, request, and network sink in one function.""" + source = """\ +def build(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + return urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + + +def dispatch(req): + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) From 9fcafb720221aada3302a3958048471a5e4704fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:06:07 +0900 Subject: [PATCH 005/105] docs(changelog): record bearer DNS TOCTOU detector --- CHANGELOG.d/892-bearer-dns-toctou-detector.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CHANGELOG.d/892-bearer-dns-toctou-detector.md diff --git a/CHANGELOG.d/892-bearer-dns-toctou-detector.md b/CHANGELOG.d/892-bearer-dns-toctou-detector.md new file mode 100644 index 00000000..67e0e8c5 --- /dev/null +++ b/CHANGELOG.d/892-bearer-dns-toctou-detector.md @@ -0,0 +1,3 @@ +## Security + +- Add HIGH built-in detector `python-bearer-preflight-dns-toctou` for bearer-authenticated Python `urllib` flows that validate a URL before dispatch but allow the network client to make a second DNS decision. The source-backed regression corpus preserves the pre-PR #898 vulnerable flow and the protected DNS-pinned HTTPS repair for security issue #892. From 1274a138c5ded2a3e09c6dd0c29044d888023220 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:06:57 +0900 Subject: [PATCH 006/105] docs(traceability): map bearer DNS TOCTOU detector evidence --- docs/TRACEABILITY.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index b9757a95..502b3b88 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -1,7 +1,7 @@ # AppGuardrail Requirements, Detection, and Evidence Traceability **Status:** Accepted cross-cutting baseline -**Last reviewed:** 2026-08-12 +**Last reviewed:** 2026-09-01 | Requirement / security class | Detector/control boundary | Evidence maturity | |---|---|---| @@ -19,12 +19,14 @@ | every retained issue claim mapped to executable detector obligation | issue-detection audit | PR #911 active-PR | | authenticated workflow-result detector evidence | issue-detection audit workflow evidence | PR #911 active-PR | | automatic scanner detection of unsafe stored-webhook SSRF pattern | built-in `python-stored-ssrf-webhook-url` rule | implemented-main through PR #910 for tested Python `set_webhook` direct and one-hop persistence flows; bounded scope | +| bearer-authenticated DNS rebinding prevention | DNS-pinned HTTPS control-plane transport | implemented-main through PR #898 for the reviewed scan-delivery boundary | +| automatic scanner detection of preflight DNS-validation TOCTOU before bearer urllib dispatch | built-in `python-bearer-preflight-dns-toctou` rule | integration evidence: PR #1080; source-backed vulnerable/fixed fixtures and production `_scan_file` regressions must remain with the detector | | structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution | ## Promotion rules - `implemented-main` requires source/tests on protected `develop`, not an issue/PR description. -- `active-PR` becomes current only after merge plus fresh protected-head required evidence. +- A PR reference records candidate/integration evidence only; `implemented-main` becomes current only after merge plus fresh protected-head required evidence. - External-engine capability must name the engine and availability; normalization does not convert it into a built-in detector. - A prevention/hardening change does not automatically promote the matching scanner-detection row; PR #924 and PR #910 were verified and promoted independently. - An issue registry mapping cannot promote an obligation unless actual detector execution derives its result from independent/closed evidence. @@ -47,10 +49,18 @@ For stored webhook/callback SSRF, trace separately: Current protected-branch evidence keeps those controls distinct: PR #924 supplies the fail-closed webhook storage boundary, and PR #910 supplies the packaged `python-stored-ssrf-webhook-url` detector plus focused regression corpus. Neither control expands the detector beyond its declared source/sink and flow contract. +## Bearer DNS TOCTOU traceability + +Security issue #892 and merged PR #898 establish the runtime defect and repair: a bearer-authenticated control-plane push first validated a URL, then `urllib` made a second DNS decision during connection; the protected repair uses DNS-pinned HTTPS so the actual connection uses the validated public address set while retaining the hostname for TLS identity verification. + +PR #1080 is the integration record for the independent scanner obligation. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> `urllib.request.Request` carrying `Authorization: Bearer ...` -> later urllib/opener dispatch. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: unauthenticated urllib delivery, validation without network dispatch, and evidence split across sibling functions are not this detector class. + +The detector deliberately does not claim general interprocedural or cross-library DNS-rebinding analysis. Alternative HTTP clients, helper/cross-file flows, differently named validation boundaries, and custom transports whose actual socket connection is independently pinned require separate evidence. Do not infer scanner maturity from the already-merged runtime repair; promotion requires the detector and its regression corpus on protected `develop` plus fresh protected-head required evidence. + ## Standards/research Existing repository docs/doctoring/security evidence remain the bibliography/source-of-truth for standards such as SARIF, CycloneDX, GitHub security interfaces, and applicable OWASP/CWE classes. Material new detector classes should add authoritative standard/CWE/OWASP references and APA 7 citations in doctoring where research/standards materially drive implementation. ## Change rule -Every new issue-class detector or product security boundary should add/update a row and its concrete test/evidence path. Stale/queued/cancelled/rate-limited/predecessor checks cannot promote evidence maturity. \ No newline at end of file +Every new issue-class detector or product security boundary should add/update a row and its concrete test/evidence path. Stale/queued/cancelled/rate-limited/predecessor checks cannot promote evidence maturity. From 3fb319ea69a704a27ca134bf70d075774ce37e5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:16:01 +0900 Subject: [PATCH 007/105] test(ssrf): isolate DNS pinning repair boundary --- .../appguardrail_bearer_dns_toctou_fixed.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_fixed.py b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_fixed.py index 480acbad..03548a1b 100644 --- a/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_fixed.py +++ b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_fixed.py @@ -1,15 +1,20 @@ -"""Reviewed AppGuardrail repair for bearer-authenticated DNS rebinding.""" +"""Minimal repaired oracle for bearer-authenticated DNS rebinding TOCTOU.""" def push_scan(url, payload, api_key): - """Dispatch through the transport that pins the validated address set.""" + """Keep the vulnerable shape but make connection reuse validated addresses.""" + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" - return post_json_pinned_https( + req = urllib.request.Request( endpoint, - payload, + data=json.dumps(payload).encode("utf-8"), + method="POST", headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", }, - timeout=15, ) + opener = PinnedHTTPSOpener.from_validated_url(url) + return opener.open(req, timeout=15) From a99b02afe57178bd5567637f0931d4f502fca498 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:16:11 +0900 Subject: [PATCH 008/105] test(ssrf): preserve protected pinned HTTPS repair --- .../appguardrail_bearer_dns_toctou_protected.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_protected.py diff --git a/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_protected.py b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_protected.py new file mode 100644 index 00000000..ccdd99dd --- /dev/null +++ b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_protected.py @@ -0,0 +1,15 @@ +"""Protected AppGuardrail repair for bearer-authenticated DNS rebinding.""" + + +def push_scan(url, payload, api_key): + """Dispatch through the reviewed transport that pins validated addresses.""" + endpoint = url.rstrip("/") + "/api/v1/scans" + return post_json_pinned_https( + endpoint, + payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + timeout=15, + ) From d61cc180cdbe8e643c5153faa57704913498e99f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:16:34 +0900 Subject: [PATCH 009/105] test(ssrf): bound DNS TOCTOU executable path --- tests/test_bearer_dns_toctou_rule.py | 71 +++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/tests/test_bearer_dns_toctou_rule.py b/tests/test_bearer_dns_toctou_rule.py index 383c3678..e3e79115 100644 --- a/tests/test_bearer_dns_toctou_rule.py +++ b/tests/test_bearer_dns_toctou_rule.py @@ -56,14 +56,81 @@ def test_historical_bearer_preflight_flow_is_detected(tmp_path): assert "CWE-918" in finding["cwe"] -def test_reviewed_pinned_https_repair_is_not_flagged(tmp_path): - """The protected transport connects to the validated address set itself.""" +def test_isolated_pinned_opener_repair_is_not_flagged(tmp_path): + """Changing only the connection primitive to a pinned opener is sufficient.""" assert not _scan_source( tmp_path, _fixture("appguardrail_bearer_dns_toctou_fixed.py"), ) +def test_protected_pinned_https_repair_is_not_flagged(tmp_path): + """Keep the actual protected PR #898 transport repair as a negative oracle.""" + assert not _scan_source( + tmp_path, + _fixture("appguardrail_bearer_dns_toctou_protected.py"), + ) + + +def test_custom_pinned_opener_is_not_assumed_to_reresolve(tmp_path): + """An arbitrary opener is not evidence that the network layer repeats DNS.""" + source = """\ +def deliver(url, payload, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + data=payload, + headers={ + "Authorization": f"Bearer {api_key}", + }, + ) + opener = DNSPinnedOpener(validated_url=url) + return opener.open(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_commented_request_and_dispatch_are_not_executable_evidence(tmp_path): + """Comments cannot donate a bearer request or second-resolution sink.""" + source = """\ +def deliver(url, payload, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + # req = urllib.request.Request( + # endpoint, + # headers={"Authorization": f"Bearer {api_key}"}, + # ) + # return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_mutually_exclusive_request_and_dispatch_are_not_one_path(tmp_path): + """Evidence from opposite branches cannot form one executable vulnerable path.""" + source = """\ +def deliver(url, payload, api_key, construct): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + if construct: + req = urllib.request.Request( + endpoint, + data=payload, + headers={ + "Authorization": f"Bearer {api_key}", + }, + ) + else: + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + def test_unauthenticated_urllib_delivery_is_not_flagged(tmp_path): """Do not promote generic URL preflight use to this credential-bearing class.""" source = """\ From 2a7403c198d1bab015e19e5cd773f5dbeae71878 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:17:13 +0900 Subject: [PATCH 010/105] fix(ssrf): require executable DNS TOCTOU flow --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 1627d7fd..a50f685c 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^[ \t]*(?:async\s+)?def\s+[A-Za-z_][A-Za-z0-9_]*\s*\([^\n]*\)\s*:\s*\n(?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,6000}?if\s+not\s+_is_safe_url\s*\(\s*(?P[A-Za-z_][A-Za-z0-9_]*)\s*\)\s*:(?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,3000}?(?P[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P=url)\.rstrip\s*\([^\n]*\)[^\n]*(?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,3000}?(?P[A-Za-z_][A-Za-z0-9_]*)\s*=\s*urllib\.request\.Request\s*\(\s*(?P=endpoint)\b(?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,2000}?["\x27]Authorization["\x27]\s*:\s*f?["\x27]Bearer\s+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27](?:(?!^[ \t]*(?:async\s+)?def\b|^[ \t]*class\b).){0,3000}?(?:urllib\.request\.urlopen\s*\(\s*(?P=req)\b|[A-Za-z_][A-Za-z0-9_]*\.open\s*\(\s*(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^[ \t]+["\x27]Authorization["\x27][ \t]*:[ \t]*f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27][^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1000}?^(?P=bodyindent)(?:return[ \t]+)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From ad7d0837fce60189c5a5e062f25aa0a317857c27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:17:29 +0900 Subject: [PATCH 011/105] test(ssrf): replay historical bearer push path --- .../appguardrail_bearer_dns_toctou_vulnerable.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_vulnerable.py b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_vulnerable.py index 90ac4119..14321007 100644 --- a/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_vulnerable.py +++ b/tests/fixtures/security_corpus/appguardrail_bearer_dns_toctou_vulnerable.py @@ -3,6 +3,8 @@ def push_scan(url, payload, api_key): """Validate once, then let urllib resolve again while carrying a bearer token.""" + if not api_key: + return None if not _is_safe_url(url): return None @@ -16,5 +18,11 @@ def push_scan(url, payload, api_key): "Authorization": f"Bearer {api_key}", }, ) - opener = urllib.request.build_opener(SafeRedirectHandler()) - return opener.open(req, timeout=15) + try: + opener = urllib.request.build_opener(SafeRedirectHandler()) + with ( + opener.open(req, timeout=15) as response + ): + return response.read() + except urllib.error.URLError: + return None From 283685755003a3a22e484be983351fc2401afaeb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:26:10 +0900 Subject: [PATCH 012/105] fix(sast): cover ordinary urllib TOCTOU layouts --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index a50f685c..a4491c0e 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^[ \t]+["\x27]Authorization["\x27][ \t]*:[ \t]*f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27][^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1000}?^(?P=bodyindent)(?:return[ \t]+)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])[^\n]*\)[ \t]*$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27][^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1000}?^(?P=bodyindent)(?:return[ \t]+)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From 9314528175888776bb243fe2bea5719d1061b295 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:27:05 +0900 Subject: [PATCH 013/105] test(sast): lock urllib TOCTOU layout boundaries --- tests/test_bearer_dns_toctou_rule.py | 63 ++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_bearer_dns_toctou_rule.py b/tests/test_bearer_dns_toctou_rule.py index e3e79115..8f29f439 100644 --- a/tests/test_bearer_dns_toctou_rule.py +++ b/tests/test_bearer_dns_toctou_rule.py @@ -56,6 +56,53 @@ def test_historical_bearer_preflight_flow_is_detected(tmp_path): assert "CWE-918" in finding["cwe"] +def test_one_line_bearer_request_is_detected(tmp_path): + """Formatting the Request on one line must not hide the DNS race.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, headers={"Authorization": f"Bearer {api_key}"}) + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_comment_before_fail_closed_guard_return_is_detected(tmp_path): + """A harmless guard comment must not make the vulnerable delivery invisible.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + # Reject destinations that fail the public URL preflight. + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_with_urlopen_bearer_dispatch_is_detected(tmp_path): + """Context-manager urlopen still performs the second DNS-sensitive dispatch.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + with urllib.request.urlopen(req, timeout=5) as response: + return response.read() +""" + assert len(_scan_source(tmp_path, source)) == 1 + + def test_isolated_pinned_opener_repair_is_not_flagged(tmp_path): """Changing only the connection primitive to a pinned opener is sufficient.""" assert not _scan_source( @@ -109,6 +156,22 @@ def deliver(url, payload, api_key): assert not _scan_source(tmp_path, source) +def test_commented_bearer_header_cannot_authenticate_live_request(tmp_path): + """A comment inside a live Request call cannot donate bearer credentials.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + # headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + def test_mutually_exclusive_request_and_dispatch_are_not_one_path(tmp_path): """Evidence from opposite branches cannot form one executable vulnerable path.""" source = """\ From 218114500e92ded8d6c4f975a2e03bd0109e8169 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:40:51 +0900 Subject: [PATCH 014/105] fix(sast): cover bearer header syntax variants --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index a4491c0e..10f62c1d 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])[^\n]*\)[ \t]*$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27][^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1000}?^(?P=bodyindent)(?:return[ \t]+)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1000}?^(?P=bodyindent)(?:return[ \t]+)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From 62dd97964df8eae8f50ff3647bb1c7cbc042ac87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:41:38 +0900 Subject: [PATCH 015/105] test(sast): lock bearer syntax variants --- tests/test_bearer_dns_toctou_rule.py | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_bearer_dns_toctou_rule.py b/tests/test_bearer_dns_toctou_rule.py index 8f29f439..37ca4c31 100644 --- a/tests/test_bearer_dns_toctou_rule.py +++ b/tests/test_bearer_dns_toctou_rule.py @@ -69,6 +69,35 @@ def deliver(url, api_key): assert len(_scan_source(tmp_path, source)) == 1 +def test_one_line_bearer_request_with_trailing_comment_is_detected(tmp_path): + """A trailing Request comment cannot erase executable bearer evidence.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, headers={"Authorization": f"Bearer {api_key}"}) # authenticated push + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_concatenated_bearer_header_is_detected(tmp_path): + """String concatenation is the same credential-bearing request semantics.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": "Bearer " + api_key}, + ) # request is still executable + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_source(tmp_path, source)) == 1 + + def test_comment_before_fail_closed_guard_return_is_detected(tmp_path): """A harmless guard comment must not make the vulnerable delivery invisible.""" source = """\ @@ -172,6 +201,22 @@ def deliver(url, api_key): assert not _scan_source(tmp_path, source) +def test_commented_concatenated_bearer_header_is_not_evidence(tmp_path): + """The new concatenation form still cannot be sourced from a comment.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + # headers={"Authorization": "Bearer " + api_key}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + def test_mutually_exclusive_request_and_dispatch_are_not_one_path(tmp_path): """Evidence from opposite branches cannot form one executable vulnerable path.""" source = """\ From da21c97d45a7c511b13c8799de983492cc3670ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:42:34 +0900 Subject: [PATCH 016/105] docs(security): record bearer TOCTOU syntax boundaries --- docs/TRACEABILITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 502b3b88..edec5808 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -53,9 +53,9 @@ Current protected-branch evidence keeps those controls distinct: PR #924 supplie Security issue #892 and merged PR #898 establish the runtime defect and repair: a bearer-authenticated control-plane push first validated a URL, then `urllib` made a second DNS decision during connection; the protected repair uses DNS-pinned HTTPS so the actual connection uses the validated public address set while retaining the hostname for TLS identity verification. -PR #1080 is the integration record for the independent scanner obligation. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> `urllib.request.Request` carrying `Authorization: Bearer ...` -> later urllib/opener dispatch. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: unauthenticated urllib delivery, validation without network dispatch, and evidence split across sibling functions are not this detector class. +PR #1080 is the integration record for the independent scanner obligation. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` carrying an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. The credential source accepts the reviewed f-string form and equivalent literal-prefix string concatenation, and ordinary one-line/multiline request syntax or trailing executable-line comments do not erase the path. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector class. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. -The detector deliberately does not claim general interprocedural or cross-library DNS-rebinding analysis. Alternative HTTP clients, helper/cross-file flows, differently named validation boundaries, and custom transports whose actual socket connection is independently pinned require separate evidence. Do not infer scanner maturity from the already-merged runtime repair; promotion requires the detector and its regression corpus on protected `develop` plus fresh protected-head required evidence. +The detector deliberately does not claim general interprocedural or cross-library DNS-rebinding analysis. Alternative HTTP clients, helper/cross-file flows, differently named validation boundaries, richer credential construction expressions, and custom transports whose actual socket connection is independently pinned require separate evidence. Do not infer scanner maturity from the already-merged runtime repair; promotion requires the detector and its regression corpus on protected `develop` plus fresh protected-head required evidence. ## Standards/research From 33b3033f7aa1cbcd55579f92cf9407dffa87f3cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:55:56 +0900 Subject: [PATCH 017/105] test(ssrf): cover assigned and replaced bearer dispatch --- .../test_bearer_dns_toctou_flow_regression.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_flow_regression.py diff --git a/tests/test_bearer_dns_toctou_flow_regression.py b/tests/test_bearer_dns_toctou_flow_regression.py new file mode 100644 index 00000000..f58ad13c --- /dev/null +++ b/tests/test_bearer_dns_toctou_flow_regression.py @@ -0,0 +1,86 @@ +"""Flow-boundary regressions for bearer-authenticated DNS TOCTOU detection.""" + +from scanner.cli.appguardrail import SCAN_RULES, _scan_file + +_RULE_ID = "python-bearer-preflight-dns-toctou" + + +def _scan_source(tmp_path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] == _RULE_ID + ] + + +def _prefix() -> str: + return """\ +def push_scan(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) +""" + + +def test_packaged_bearer_dns_toctou_rule_is_loaded(): + assert sum(rule["id"] == _RULE_ID for rule in SCAN_RULES) == 1 + + +def test_assigned_urlopen_dispatch_is_detected(tmp_path): + source = _prefix() + """\ + response = urllib.request.urlopen( + req, + ) + return response +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_assigned_reviewed_opener_dispatch_is_detected(tmp_path): + source = _prefix() + """\ + opener = urllib.request.build_opener(SafeRedirectHandler()) + response = opener.open( + req, + ) + return response +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_unreachable_sink_after_body_return_is_not_flagged(tmp_path): + source = _prefix() + """\ + return None + response = urllib.request.urlopen(req) +""" + assert not _scan_source(tmp_path, source) + + +def test_reassigned_request_before_dispatch_is_not_flagged(tmp_path): + source = _prefix() + """\ + req = object() + response = urllib.request.urlopen(req) + return response +""" + assert not _scan_source(tmp_path, source) + + +def test_reassigned_endpoint_before_request_is_not_flagged(tmp_path): + source = """\ +def push_scan(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + endpoint = "https://example.com/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req) +""" + assert not _scan_source(tmp_path, source) From e7a84fbf3dfa52b50c8e6fb758ec4556ba5dcaaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:57:14 +0900 Subject: [PATCH 018/105] fix(ssrf): bind bearer dispatch to live request flow --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 10f62c1d..22b46616 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1000}?^(?P=bodyindent)(?:return[ \t]+)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=).){0,1000}?^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From 0782bab4f99cab4ceb47c3ce69ea605fc06ecd1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:02:11 +0900 Subject: [PATCH 019/105] test(ssrf): preserve unsafe self-derived reassignments --- .../test_bearer_dns_toctou_flow_regression.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_bearer_dns_toctou_flow_regression.py b/tests/test_bearer_dns_toctou_flow_regression.py index f58ad13c..b0177f6d 100644 --- a/tests/test_bearer_dns_toctou_flow_regression.py +++ b/tests/test_bearer_dns_toctou_flow_regression.py @@ -53,6 +53,31 @@ def test_assigned_reviewed_opener_dispatch_is_detected(tmp_path): assert len(_scan_source(tmp_path, source)) == 1 +def test_endpoint_self_derivation_preserves_detection(tmp_path): + source = """\ +def push_scan(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + endpoint = endpoint + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req) +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_request_preserving_reassignment_preserves_detection(tmp_path): + source = _prefix() + """\ + req = req + response = urllib.request.urlopen(req) + return response +""" + assert len(_scan_source(tmp_path, source)) == 1 + + def test_unreachable_sink_after_body_return_is_not_flagged(tmp_path): source = _prefix() + """\ return None From d02e064c02f079a9ab31aedcfd5051dbfce5fd89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:04:03 +0900 Subject: [PATCH 020/105] fix(ssrf): preserve unsafe self-derived bearer flow --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 22b46616..1ed29870 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=).){0,1000}?^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^\n#]*\b(?:(?P=req)|(?P=endpoint))\b)).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^\n#]*\b(?:(?P=req)|(?P=endpoint))\b)).){0,1000}?^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From 41d583372533e994dc155c879662f00e0b600db0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:00:06 +0900 Subject: [PATCH 021/105] test(sast): preserve bearer DNS TOCTOU flow boundaries --- tests/test_bearer_dns_toctou_rule.py | 160 +++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/tests/test_bearer_dns_toctou_rule.py b/tests/test_bearer_dns_toctou_rule.py index 37ca4c31..e491cf23 100644 --- a/tests/test_bearer_dns_toctou_rule.py +++ b/tests/test_bearer_dns_toctou_rule.py @@ -285,3 +285,163 @@ def dispatch(req): return urllib.request.urlopen(req, timeout=5) """ assert not _scan_source(tmp_path, source) + + +def test_assigned_urlopen_dispatch_is_detected(tmp_path): + """Assignment form does not remove the second-resolution network sink.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + response = urllib.request.urlopen( + req, + timeout=5, + ) + return response +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_assigned_reviewed_opener_dispatch_is_detected(tmp_path): + """Assignment through the reviewed urllib opener remains a vulnerable sink.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + opener = urllib.request.build_opener(SafeRedirectHandler()) + response = opener.open( + req, + timeout=5, + ) + return response +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_dead_sink_after_unconditional_return_is_not_flagged(tmp_path): + """Unreachable dispatch cannot complete the credential exfiltration path.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return None + response = urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_unrelated_request_replacement_is_not_flagged(tmp_path): + """Replacing the tracked request breaks the vulnerable request provenance.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + req = object() + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_unrelated_endpoint_replacement_is_not_flagged(tmp_path): + """Replacing the URL-derived endpoint before Request breaks destination flow.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + endpoint = "https://fixed.example/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_endpoint_self_derivation_remains_detected(tmp_path): + """A reassignment derived from the tracked endpoint preserves unsafe provenance.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + endpoint = endpoint + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_request_preserving_reassignment_remains_detected(tmp_path): + """A direct self-assignment cannot sanitize the tracked bearer request.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + req = req + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_endpoint_name_only_inside_replacement_string_is_not_flagged(tmp_path): + """Quoted identifier text is not executable provenance for endpoint flow.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + endpoint = choose("endpoint") + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_request_name_only_inside_replacement_string_is_not_flagged(tmp_path): + """Quoted identifier text is not executable provenance for request flow.""" + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + req = choose("req") + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) From e23bc99a1cba99dbe143dc96bf4c595b5b305295 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:00:52 +0900 Subject: [PATCH 022/105] fix(sast): ignore quoted names in TOCTOU reassignment flow --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 1ed29870..4dbfbfac 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^\n#]*\b(?:(?P=req)|(?P=endpoint))\b)).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^\n#]*\b(?:(?P=req)|(?P=endpoint))\b)).){0,1000}?^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=req)|(?P=endpoint))\b)).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=req)|(?P=endpoint))\b)).){0,1000}?^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From 17c89c7586334076c4ec916a9806dfd28c6c2d4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:01:14 +0900 Subject: [PATCH 023/105] docs: record TOCTOU reassignment provenance boundary --- docs/TRACEABILITY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index edec5808..19089c9f 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -55,6 +55,8 @@ Security issue #892 and merged PR #898 establish the runtime defect and repair: PR #1080 is the integration record for the independent scanner obligation. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` carrying an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. The credential source accepts the reviewed f-string form and equivalent literal-prefix string concatenation, and ordinary one-line/multiline request syntax or trailing executable-line comments do not erase the path. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector class. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. +Simple self-derived assignments preserve the tracked path when an executable tracked identifier appears before any string literal or comment on the right-hand side, including `endpoint = endpoint + ...`, URL-derived endpoint updates, and `req = req`. An assignment where the tracked name occurs only as quoted data, such as `endpoint = choose("endpoint")` or `req = choose("req")`, is a provenance break and must not create a HIGH finding. More general wrapper/helper-mediated value flow that places a literal before the tracked identifier is outside this bounded regex detector and requires separate executable evidence rather than speculative flow inference. Paired production `_scan_file` regressions keep both the self-derived positives and quoted-name replacement negatives stable. + The detector deliberately does not claim general interprocedural or cross-library DNS-rebinding analysis. Alternative HTTP clients, helper/cross-file flows, differently named validation boundaries, richer credential construction expressions, and custom transports whose actual socket connection is independently pinned require separate evidence. Do not infer scanner maturity from the already-merged runtime repair; promotion requires the detector and its regression corpus on protected `develop` plus fresh protected-head required evidence. ## Standards/research From 0472b40a376709db085060c91fda2fec6c43aa92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:09:25 +0900 Subject: [PATCH 024/105] fix(sast): preserve credential and conditional TOCTOU flow boundaries --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 4dbfbfac..c3e6c571 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=req)|(?P=endpoint))\b)).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=req)|(?P=endpoint))\b)).){0,1000}?^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From 664fba52b6dba471e9016952b0447c20d946376a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:09:39 +0900 Subject: [PATCH 025/105] test(sast): cover TOCTOU credential and nested-dispatch edges --- ...r_dns_toctou_current_review_regressions.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_current_review_regressions.py diff --git a/tests/test_bearer_dns_toctou_current_review_regressions.py b/tests/test_bearer_dns_toctou_current_review_regressions.py new file mode 100644 index 00000000..cc3014b0 --- /dev/null +++ b/tests/test_bearer_dns_toctou_current_review_regressions.py @@ -0,0 +1,74 @@ +"""Current-head flow-boundary regressions for bearer DNS TOCTOU detection.""" + +from scanner.cli.appguardrail import _scan_file + +_RULE_ID = "python-bearer-preflight-dns-toctou" + + +def _scan_source(tmp_path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] == _RULE_ID + ] + + +def _bearer_source(request_url: str = "endpoint", dispatch: str = "return urllib.request.urlopen(req, timeout=5)") -> str: + return f'''\ +def deliver(url, api_key, enabled=True): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + {request_url}, + headers={{"Authorization": f"Bearer {{api_key}}"}}, + ) + {dispatch} +''' + + +def test_keyword_url_bearer_request_is_detected(tmp_path): + """`Request(url=endpoint, ...)` retains the same vulnerable destination flow.""" + assert len(_scan_source(tmp_path, _bearer_source("url=endpoint"))) == 1 + + +def test_conditional_urlopen_dispatch_is_detected(tmp_path): + """A nested conditional dispatch remains an executable vulnerable path.""" + source = _bearer_source( + dispatch="if enabled:\n return urllib.request.urlopen(req, timeout=5)" + ) + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_unauthenticated_request_rebuild_breaks_bearer_provenance(tmp_path): + """Rebuilding the request without Authorization must not inherit stale credentials.""" + source = _bearer_source( + dispatch="req = urllib.request.Request(endpoint)\n return urllib.request.urlopen(req, timeout=5)" + ) + assert not _scan_source(tmp_path, source) + + +def test_remove_authorization_header_breaks_bearer_provenance(tmp_path): + """A supported explicit Authorization removal must suppress stale bearer evidence.""" + source = _bearer_source( + dispatch='req.remove_header("Authorization")\n return urllib.request.urlopen(req, timeout=5)' + ) + assert not _scan_source(tmp_path, source) + + +def test_pop_authorization_header_breaks_bearer_provenance(tmp_path): + """Removing Authorization from the request header mapping breaks credential flow.""" + source = _bearer_source( + dispatch='req.headers.pop("Authorization", None)\n return urllib.request.urlopen(req, timeout=5)' + ) + assert not _scan_source(tmp_path, source) + + +def test_delete_authorization_header_breaks_bearer_provenance(tmp_path): + """Deleting Authorization before dispatch is another explicit credential barrier.""" + source = _bearer_source( + dispatch='del req.headers["Authorization"]\n return urllib.request.urlopen(req, timeout=5)' + ) + assert not _scan_source(tmp_path, source) From ab02ca38bb50340d1f3aafef5bf482ef092eaf9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:03:56 +0900 Subject: [PATCH 026/105] fix(sast): tighten bearer DNS TOCTOU provenance --- scanner/rules/ssrf.yml | 88 ++++++-- ...est_bearer_dns_toctou_review_boundaries.py | 202 ++++++++++++++++++ 2 files changed, 276 insertions(+), 14 deletions(-) create mode 100644 tests/test_bearer_dns_toctou_review_boundaries.py diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index c3e6c571..80b1a62a 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,17 +14,77 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?=[^\n]*\b(?P=endpoint)\b)(?=[^\n\#]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' - message: | - A bearer-authenticated urllib request is sent after a separate URL-safety - preflight. The network client can resolve the hostname again after validation, - creating a DNS-rebinding TOCTOU path for credential-bearing traffic. Connect - through the already-validated address set, or enforce an equivalent egress - boundary while preserving TLS hostname verification. - [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] - [CWE-918 - Server-Side Request Forgery] - severity: HIGH - languages: [python] - prefilter: [_is_safe_url, urllib.request.Request, Authorization, Bearer] - cwe: [CWE-367, CWE-918] - owasp: [A10:2021] + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[^\n\#]*\bheaders[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$ +Η–ÈJÊOÊÎ\›ÈJVÈJŠOÊÔY[™Ú[ +W–×——J—ŠÏJΊÈWŠÔX›ÙZ[™[ +W +VÈJŠΗÖ×——JŠOÉ +KŠ^Ì ML O׊ÈVÈJ—ÊV×——J—šXY\œÖÈJVÈJ—ÊΊÈWJKŠ^Ì L OÖÈ— ×P]]Üš^˜][Û–È— ×VÈJŽ–ÈJŠΙÖÈ— ×P™X\™\–ÈJ×ÏÖÐKV˜K^—×VÐKV˜K^Œ NW×J—OÖ׈— ×—J–È— ×_È— ×P™X\™\–ÈJÖÈ— ×VÈJ— +ÖÈJ–ÐKV˜K^—×VÐKV˜K^Œ NW×JŠJJΊÈWŠÔX›ÙZ[™[ +W +VÈJŠΗÖ×——JŠOÉ +JÈW–ÈJ—ÊV×——J—Š^Ì ÌO׊ÈVÈJ—ÊV×——J–È— ×P]]Üš^˜][Û–È— ×VÈJŽ–ÈJŠΙÖÈ— ×P™X\™\–ÈJ×ÏÖÐKV˜K^—×VÐKV˜K^Œ NW×J—OÖ׈— ×—J–È— ×_È— ×P™X\™\–ÈJÖÈ— ×VÈJ— +ÖÈJ–ÐKV˜K^—×VÐKV˜K^Œ NW×JŠV×——J—ŠΊÈWŠÔX›ÙZ[™[ +W +VÈJŠΗÖ×——JŠOÉ +V×——J—Š^Ì ÌO׊ÔX›ÙZ[™[ +W +VÈJŠΗÖ×——JŠOÉ +WŠÈJΊÈWŠÔYYš[™[ +JΘ\Þ[˜ÖÈJÊOÙY—ŸŠÔYYš[™[ +XÛ\Ü׊KŠ^Ì Ì O׊Ô]]™[[Ý™Z[™[ŠÔX›ÙZ[™[ +VÈJÊJΊÔ\™\JW œ™[[Ý™WÚXY\–ÈJ— +ÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ— +_ +Ô\™\JW šXY\œ× œÜÈJ— +ÈJ–È— ×P]]Üš^˜][Û–È— ×V×——J— +_[ÈJÊÔ\™\JW šXY\œÖÈJ—ÖÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ—JV×——J—ŠΊÈWŠÔX›ÙZ[™[ +WÊKŠ^Ì ML O׊ÔX]]™[[Ý™Z[™[ +JΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— +ÈJŠΗ–ÈJÊOÊÔ\™\JWŠJΊÈWŠÔYYš[™[ +JΘ\Þ[˜ÖÈJÊOÙY—ŸŠÔYYš[™[ +XÛ\Ü׊JÈWŠÔX›ÙZ[™[ +JΜ™]\›Ÿ˜Z\ÙJWŠJÈWŠÔX›ÙZ[™[ +JÔ\™\JVÈJVÈJŠÈV׈— ׈×J—ŠÔ\™\JWŠJJÈWŠÔX›ÙZ[™[ +JΊÔ\™\JW œ™[[Ý™WÚXY\–ÈJ— +ÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ— +_ +Ô\™\JW šXY\œ× œÜÈJ— +ÈJ–È— ×P]]Üš^˜][Û–È— ×V×——J— +_[ÈJÊÔ\™\JW šXY\œÖÈJ—ÖÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ—JJKŠ^Ì Ì OÊΗŠÔX›ÙZ[™[ +JÔÜ[™\ŒO–ÐKV˜K^—×VÐKV˜K^Œ NW×JŠVÈJVÈJ\›X— œ™\]Y\Ý ˜Z[ÛÜ[™\–ÈJ— +ÈJ”ØY™T™Y\™XÝ[™\–ÈJ— +ÈJ— +VÈJ— +VÈJ–×——J—ŠΊÈWŠÔYYš[™[ +JΘ\Þ[˜ÖÈJÊOÙY—ŸŠÔYYš[™[ +XÛ\Ü׊JÈWŠÔX›ÙZ[™[ +JΜ™]\›Ÿ˜Z\ÙJWŠJÈWŠÔX›ÙZ[™[ +JÔ\™\JVÈJVÈJŠÈV׈— ׈×J—ŠÔ\™\JWŠJJÈWŠÔX›ÙZ[™[ +JΊÔ\™\JW œ™[[Ý™WÚXY\–ÈJ— +ÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ— +_ +Ô\™\JW šXY\œ× œÜÈJ— +ÈJ–È— ×P]]Üš^˜][Û–È— ×V×——J— +_[ÈJÊÔ\™\JW šXY\œÖÈJ—ÖÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ—JJKŠ^Ì L O׊ÔX›ÙZ[™[ +VÈJŠΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÊÔ[Ü[™\ŒJW ›Ü[–ÈJ— +ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ +]žVÈJŽ–×——J——ŠÔžZ[™[ ÏŠÔX›ÙZ[™[ +VÈJÊJÔÜ[™\Œ–ÐKV˜K^—×VÐKV˜K^Œ NW×JŠVÈJVÈJ\›X— œ™\]Y\Ý ˜Z[ÛÜ[™\–ÈJ— +ÈJ”ØY™T™Y\™XÝ[™\–ÈJ— +ÈJ— +VÈJ— +VÈJ–×——J—ŠΊÈWŠÔX›ÙZ[™[ +JΙ^Ù\š[˜[JWŠKŠ^Ì LŒ O×–ÈJÊΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÊÔ[Ü[™\ŒŠW ›Ü[–ÈJ— +ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ +VÈJŠΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— +ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ +VÈJÚ]ÈJÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— +ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ +]žVÈJŽ–×——J——ŠÔ\›žZ[™[ ÏŠÔX›ÙZ[™[ +VÈJÊJΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— +ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ +]žVÈJŽ–×——J——ŠÔÚ]žZ[™[ ÏŠÔX›ÙZ[™[ +VÈJÊ]Ú]ÈJÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— +ÈJŠΗ–ÈJÊOÊÔ\™\JWŠIˆY\ÜØYÙNˆˆH™X\™\‹X]][XØ]Y\›Xˆ™\]Y\Ý\ÈÙ[Y\ˆHÙ\\˜]HT“ \ØY™]Bˆ™Y›YÚ ˆH™]ÛÜšÈÛY[Ø[ˆ™\ÛÛ™HHÜݘ[YHYØZ[ˆY\ˆ˜[Y][Û‹ˆÜ™X][™ÈH”Ë\™Xš[™[™ÈÐÕÕH]›ÜˆÜ™Y[X[ X™X\š[™È˜Y™šXˈÛÛ›™X݈›ÝYÚH[™XYK]˜[Y]YY™\ÜÈÙ] ܈[™›Ü˜ÙH[ˆ\]Z]˜[[YÜ™\܈›Ý[™\žHÚ[H™\Ù\š[™ÈÈÜݘ[YH™\šYšXØ][Û‹‚ˆÐÕÑKLÍÈ H[YK[Ù‹XÚXÚÈ[YK[Ù‹]\ÙH +ÐÕÕJH˜XÙHÛÛ™][Û—BˆÐÕÑKNLN HÙ\™\‹TÚYH™\]Y\ݛܙÙ\žWBˆÙ]™\š]NˆQÒˆ[™ÝXYÙ\ΈÜ]Û—Bˆ™Yš[\Žˆ×Ú\×ÜØY™WÝ\› \›X‹œ™\]Y\Ý ”™\]Y\Ý ]]Üš^˜][Û‹™X\™\—BˆÝÙNˆÐÕÑKLÍËÕÑKNLNBˆÝØ\܈ÐLLŒŒ ŒWB \ No newline at end of file diff --git a/tests/test_bearer_dns_toctou_review_boundaries.py b/tests/test_bearer_dns_toctou_review_boundaries.py new file mode 100644 index 00000000..40bc34b6 --- /dev/null +++ b/tests/test_bearer_dns_toctou_review_boundaries.py @@ -0,0 +1,202 @@ +"""Current-review regression boundaries for bearer DNS-validation TOCTOU detection.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_RULE_ID = "python-bearer-preflight-dns-toctou" + + +def _scan_source(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] == _RULE_ID + ] + + +def test_one_line_request_body_authorization_is_not_header_evidence(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + data={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_multiline_request_body_authorization_is_not_header_evidence(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + data={ + "Authorization": f"Bearer {api_key}", + }, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_fixed_request_url_cannot_borrow_endpoint_from_other_header(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + "https://fixed.example/api", + headers={ + "Authorization": f"Bearer {api_key}", + "X-Endpoint": endpoint, + }, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_unrelated_url_replacement_breaks_preflight_provenance(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + url = "https://fixed.example" + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_self_derived_validated_url_preserves_preflight_provenance(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + url = url.rstrip("/") + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_nested_same_branch_header_removal_breaks_credential_path(tmp_path): + source = """\ +def deliver(url, api_key, disable): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if disable: + req.remove_header("Authorization") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_nested_same_branch_header_pop_breaks_credential_path(tmp_path): + source = """\ +def deliver(url, api_key, disable): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if disable: + req.headers.pop("Authorization", None) + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_nested_same_branch_header_delete_breaks_credential_path(tmp_path): + source = """\ +def deliver(url, api_key, disable): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if disable: + del req.headers["Authorization"] + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_opposite_branch_header_removal_does_not_sanitize_dispatch(tmp_path): + source = """\ +def deliver(url, api_key, disable): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if disable: + req.remove_header("Authorization") + else: + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_first_positional_request_url_still_detected(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_keyword_request_url_still_detected(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + url=endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_source(tmp_path, source)) == 1 From 2374813e9e688a30cbf431a12ef100a9dcbf5c76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:08:52 +0900 Subject: [PATCH 027/105] fix(sast): repair TOCTOU regression corpus --- scanner/rules/ssrf.yml | 88 +++---------------- ...est_bearer_dns_toctou_review_boundaries.py | 2 +- 2 files changed, 15 insertions(+), 75 deletions(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 80b1a62a..56c5308b 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,77 +14,17 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[^\n\#]*\bheaders[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$ -Η–ÈJÊOÊÎ\›ÈJVÈJŠOÊÔY[™Ú[ -W–×——J—ŠÏJΊÈWŠÔX›ÙZ[™[ -W -VÈJŠΗÖ×——JŠOÉ -KŠ^Ì ML O׊ÈVÈJ—ÊV×——J—šXY\œÖÈJVÈJ—ÊΊÈWJKŠ^Ì L OÖÈ— ×P]]Üš^˜][Û–È— ×VÈJŽ–ÈJŠΙÖÈ— ×P™X\™\–ÈJ×ÏÖÐKV˜K^—×VÐKV˜K^Œ NW×J—OÖ׈— ×—J–È— ×_È— ×P™X\™\–ÈJÖÈ— ×VÈJ— -ÖÈJ–ÐKV˜K^—×VÐKV˜K^Œ NW×JŠJJΊÈWŠÔX›ÙZ[™[ -W -VÈJŠΗÖ×——JŠOÉ -JÈW–ÈJ—ÊV×——J—Š^Ì ÌO׊ÈVÈJ—ÊV×——J–È— ×P]]Üš^˜][Û–È— ×VÈJŽ–ÈJŠΙÖÈ— ×P™X\™\–ÈJ×ÏÖÐKV˜K^—×VÐKV˜K^Œ NW×J—OÖ׈— ×—J–È— ×_È— ×P™X\™\–ÈJÖÈ— ×VÈJ— -ÖÈJ–ÐKV˜K^—×VÐKV˜K^Œ NW×JŠV×——J—ŠΊÈWŠÔX›ÙZ[™[ -W -VÈJŠΗÖ×——JŠOÉ -V×——J—Š^Ì ÌO׊ÔX›ÙZ[™[ -W -VÈJŠΗÖ×——JŠOÉ -WŠÈJΊÈWŠÔYYš[™[ -JΘ\Þ[˜ÖÈJÊOÙY—ŸŠÔYYš[™[ -XÛ\Ü׊KŠ^Ì Ì O׊Ô]]™[[Ý™Z[™[ŠÔX›ÙZ[™[ -VÈJÊJΊÔ\™\JW œ™[[Ý™WÚXY\–ÈJ— -ÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ— -_ -Ô\™\JW šXY\œ× œÜÈJ— -ÈJ–È— ×P]]Üš^˜][Û–È— ×V×——J— -_[ÈJÊÔ\™\JW šXY\œÖÈJ—ÖÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ—JV×——J—ŠΊÈWŠÔX›ÙZ[™[ -WÊKŠ^Ì ML O׊ÔX]]™[[Ý™Z[™[ -JΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— -ÈJŠΗ–ÈJÊOÊÔ\™\JWŠJΊÈWŠÔYYš[™[ -JΘ\Þ[˜ÖÈJÊOÙY—ŸŠÔYYš[™[ -XÛ\Ü׊JÈWŠÔX›ÙZ[™[ -JΜ™]\›Ÿ˜Z\ÙJWŠJÈWŠÔX›ÙZ[™[ -JÔ\™\JVÈJVÈJŠÈV׈— ׈×J—ŠÔ\™\JWŠJJÈWŠÔX›ÙZ[™[ -JΊÔ\™\JW œ™[[Ý™WÚXY\–ÈJ— -ÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ— -_ -Ô\™\JW šXY\œ× œÜÈJ— -ÈJ–È— ×P]]Üš^˜][Û–È— ×V×——J— -_[ÈJÊÔ\™\JW šXY\œÖÈJ—ÖÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ—JJKŠ^Ì Ì OÊΗŠÔX›ÙZ[™[ -JÔÜ[™\ŒO–ÐKV˜K^—×VÐKV˜K^Œ NW×JŠVÈJVÈJ\›X— œ™\]Y\Ý ˜Z[ÛÜ[™\–ÈJ— -ÈJ”ØY™T™Y\™XÝ[™\–ÈJ— -ÈJ— -VÈJ— -VÈJ–×——J—ŠΊÈWŠÔYYš[™[ -JΘ\Þ[˜ÖÈJÊOÙY—ŸŠÔYYš[™[ -XÛ\Ü׊JÈWŠÔX›ÙZ[™[ -JΜ™]\›Ÿ˜Z\ÙJWŠJÈWŠÔX›ÙZ[™[ -JÔ\™\JVÈJVÈJŠÈV׈— ׈×J—ŠÔ\™\JWŠJJÈWŠÔX›ÙZ[™[ -JΊÔ\™\JW œ™[[Ý™WÚXY\–ÈJ— -ÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ— -_ -Ô\™\JW šXY\œ× œÜÈJ— -ÈJ–È— ×P]]Üš^˜][Û–È— ×V×——J— -_[ÈJÊÔ\™\JW šXY\œÖÈJ—ÖÈJ–È— ×P]]Üš^˜][Û–È— ×VÈJ—JJKŠ^Ì L O׊ÔX›ÙZ[™[ -VÈJŠΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÊÔ[Ü[™\ŒJW ›Ü[–ÈJ— -ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ -]žVÈJŽ–×——J——ŠÔžZ[™[ ÏŠÔX›ÙZ[™[ -VÈJÊJÔÜ[™\Œ–ÐKV˜K^—×VÐKV˜K^Œ NW×JŠVÈJVÈJ\›X— œ™\]Y\Ý ˜Z[ÛÜ[™\–ÈJ— -ÈJ”ØY™T™Y\™XÝ[™\–ÈJ— -ÈJ— -VÈJ— -VÈJ–×——J—ŠΊÈWŠÔX›ÙZ[™[ -JΙ^Ù\š[˜[JWŠKŠ^Ì LŒ O×–ÈJÊΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÊÔ[Ü[™\ŒŠW ›Ü[–ÈJ— -ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ -VÈJŠΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— -ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ -VÈJÚ]ÈJÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— -ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ -]žVÈJŽ–×——J——ŠÔ\›žZ[™[ ÏŠÔX›ÙZ[™[ -VÈJÊJΜ™]\›–ÈJßÐKV˜K^—×VÐKV˜K^Œ NW×J–ÈJVÈJŠOÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— -ÈJŠΗ–ÈJÊOÊÔ\™\JWŸŠÔX›ÙZ[™[ -]žVÈJŽ–×——J——ŠÔÚ]žZ[™[ ÏŠÔX›ÙZ[™[ -VÈJÊ]Ú]ÈJÝ\›X— œ™\]Y\Ý \›Ü[–ÈJ— -ÈJŠΗ–ÈJÊOÊÔ\™\JWŠIˆY\ÜØYÙNˆˆH™X\™\‹X]][XØ]Y\›Xˆ™\]Y\Ý\ÈÙ[Y\ˆHÙ\\˜]HT“ \ØY™]Bˆ™Y›YÚ ˆH™]ÛÜšÈÛY[Ø[ˆ™\ÛÛ™HHÜݘ[YHYØZ[ˆY\ˆ˜[Y][Û‹ˆÜ™X][™ÈH”Ë\™Xš[™[™ÈÐÕÕH]›ÜˆÜ™Y[X[ X™X\š[™È˜Y™šXˈÛÛ›™X݈›ÝYÚH[™XYK]˜[Y]YY™\ÜÈÙ] ܈[™›Ü˜ÙH[ˆ\]Z]˜[[YÜ™\܈›Ý[™\žHÚ[H™\Ù\š[™ÈÈÜݘ[YH™\šYšXØ][Û‹‚ˆÐÕÑKLÍÈ H[YK[Ù‹XÚXÚÈ[YK[Ù‹]\ÙH -ÐÕÕJH˜XÙHÛÛ™][Û—BˆÐÕÑKNLN HÙ\™\‹TÚYH™\]Y\ݛܙÙ\žWBˆÙ]™\š]NˆQÒˆ[™ÝXYÙ\ΈÜ]Û—Bˆ™Yš[\Žˆ×Ú\×ÜØY™WÝ\› \›X‹œ™\]Y\Ý ”™\]Y\Ý ]]Üš^˜][Û‹™X\™\—BˆÝÙNˆÐÕÑKLÍËÕÑKNLNBˆÝØ\܈ÐLLŒŒ ŒWB \ No newline at end of file + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[^\n\#]*\bheaders[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?=(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$).){0,1500}?^(?![ \t]*\#)[^\n]*\bheaders[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + message: | + A bearer-authenticated urllib request is sent after a separate URL-safety + preflight. The network client can resolve the hostname again after validation, + creating a DNS-rebinding TOCTOU path for credential-bearing traffic. Connect + through the already-validated address set, or enforce an equivalent egress + boundary while preserving TLS hostname verification. + [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] + [CWE-918 - Server-Side Request Forgery] + severity: HIGH + languages: [python] + prefilter: [_is_safe_url, urllib.request.Request, Authorization, Bearer] + cwe: [CWE-367, CWE-918] + owasp: [A10:2021] diff --git a/tests/test_bearer_dns_toctou_review_boundaries.py b/tests/test_bearer_dns_toctou_review_boundaries.py index 40bc34b6..51726145 100644 --- a/tests/test_bearer_dns_toctou_review_boundaries.py +++ b/tests/test_bearer_dns_toctou_review_boundaries.py @@ -159,7 +159,7 @@ def deliver(url, api_key, disable): if not _is_safe_url(url): return None endpoint = url.rstrip("/") + "/api/v1/scans" - req = urllib.request.Request.Request( + req = urllib.request.Request( endpoint, headers={"Authorization": f"Bearer {api_key}"}, ) From c3ccac7bc41fb904db22ed99a52f9abf1c619a90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:16:05 +0900 Subject: [PATCH 028/105] docs(security): record bearer TOCTOU provenance boundaries --- docs/TRACEABILITY.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 19089c9f..d9d24063 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -1,7 +1,7 @@ # AppGuardrail Requirements, Detection, and Evidence Traceability **Status:** Accepted cross-cutting baseline -**Last reviewed:** 2026-09-01 +**Last reviewed:** 2026-09-02 | Requirement / security class | Detector/control boundary | Evidence maturity | |---|---|---| @@ -55,6 +55,10 @@ Security issue #892 and merged PR #898 establish the runtime defect and repair: PR #1080 is the integration record for the independent scanner obligation. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` carrying an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. The credential source accepts the reviewed f-string form and equivalent literal-prefix string concatenation, and ordinary one-line/multiline request syntax or trailing executable-line comments do not erase the path. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector class. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. +Request construction is destination- and credential-bound rather than token-text-bound. The tracked endpoint must be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. Likewise, `Authorization: Bearer ...` counts only as request-header evidence, not when identical-looking text appears in `data=` or another request-body value. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. + +Credential-removal barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")`, `headers.pop("Authorization", ...)`, or `del headers["Authorization"]` before dispatch breaks bearer provenance; removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. The production review-boundary regressions preserve both cases together with first-positional and keyword-URL positives so later regex changes cannot recover recall by reintroducing these HIGH false positives. + Simple self-derived assignments preserve the tracked path when an executable tracked identifier appears before any string literal or comment on the right-hand side, including `endpoint = endpoint + ...`, URL-derived endpoint updates, and `req = req`. An assignment where the tracked name occurs only as quoted data, such as `endpoint = choose("endpoint")` or `req = choose("req")`, is a provenance break and must not create a HIGH finding. More general wrapper/helper-mediated value flow that places a literal before the tracked identifier is outside this bounded regex detector and requires separate executable evidence rather than speculative flow inference. Paired production `_scan_file` regressions keep both the self-derived positives and quoted-name replacement negatives stable. The detector deliberately does not claim general interprocedural or cross-library DNS-rebinding analysis. Alternative HTTP clients, helper/cross-file flows, differently named validation boundaries, richer credential construction expressions, and custom transports whose actual socket connection is independently pinned require separate evidence. Do not infer scanner maturity from the already-merged runtime repair; promotion requires the detector and its regression corpus on protected `develop` plus fresh protected-head required evidence. From 7558633a6dbd3b5917353230918c57ab42bc3915 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:26:37 +0900 Subject: [PATCH 029/105] fix(sast): detect restored bearer credentials after preflight --- scanner/rules/ssrf_bearer_restore.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 scanner/rules/ssrf_bearer_restore.yml diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml new file mode 100644 index 00000000..9636696c --- /dev/null +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -0,0 +1,16 @@ +rules: + - id: python-bearer-preflight-dns-toctou + patterns: + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,2000}?(?P=endpoint)\b(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,2000}?headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1500}?(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1500}?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b' + message: | + A bearer-authenticated urllib request is dispatched after a separate URL-safety + preflight even though an Authorization header was removed and then restored. + Restoring the Bearer credential re-establishes the credential-bearing DNS + re-resolution path; connect through the already-validated address set instead. + [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] + [CWE-918 - Server-Side Request Forgery] + severity: HIGH + languages: [python] + prefilter: [_is_safe_url, urllib.request.Request, Authorization, Bearer, urllib.request.urlopen] + cwe: [CWE-367, CWE-918] + owasp: [A10:2021] From 39aeaa919b40718131384f18a5a6fef51ef505f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:26:53 +0900 Subject: [PATCH 030/105] test(sast): preserve bearer restoration TOCTOU boundaries --- ..._bearer_dns_toctou_restored_credentials.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_restored_credentials.py diff --git a/tests/test_bearer_dns_toctou_restored_credentials.py b/tests/test_bearer_dns_toctou_restored_credentials.py new file mode 100644 index 00000000..6dd0d317 --- /dev/null +++ b/tests/test_bearer_dns_toctou_restored_credentials.py @@ -0,0 +1,92 @@ +"""Regression boundaries for restored Bearer credentials after DNS preflight.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_RULE_ID = "python-bearer-preflight-dns-toctou" + + +def _scan_source(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] == _RULE_ID + ] + + +def test_remove_then_add_header_restores_bearer_path(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rotate: + req.remove_header("Authorization") + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_pop_then_header_assignment_restores_bearer_path(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rotate: + req.headers.pop("Authorization", None) + req.headers["Authorization"] = "Bearer " + api_key + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_remove_without_restoration_stays_non_credentialed(tmp_path): + source = """\ +def deliver(url, api_key, disable): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if disable: + req.remove_header("Authorization") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_restoring_non_bearer_authorization_does_not_recreate_rule_path(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rotate: + req.remove_header("Authorization") + req.add_header("Authorization", "Basic fixed") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) From ab5c7e1b2d239ef2aa38df63fb0263be1179f692 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:56:05 +0900 Subject: [PATCH 031/105] fix(sast): bound restored bearer DNS flow --- scanner/rules/ssrf_bearer_restore.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index 9636696c..17540fd1 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,2000}?(?P=endpoint)\b(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,2000}?headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1500}?(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1500}?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[^\n\#]*\bheaders[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?=(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$).){0,1500}?^(?![ \t]*\#)[^\n]*\bheaders[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety preflight even though an Authorization header was removed and then restored. From 99e7ddb27ab0650c9cad8b2e60b31ddbabe1d53e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:56:38 +0900 Subject: [PATCH 032/105] test(sast): cover restored bearer flow barriers --- ..._bearer_dns_toctou_restored_credentials.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/test_bearer_dns_toctou_restored_credentials.py b/tests/test_bearer_dns_toctou_restored_credentials.py index 6dd0d317..67ddd521 100644 --- a/tests/test_bearer_dns_toctou_restored_credentials.py +++ b/tests/test_bearer_dns_toctou_restored_credentials.py @@ -55,6 +55,26 @@ def deliver(url, api_key, rotate): assert len(_scan_source(tmp_path, source)) == 1 +def test_remove_then_restore_with_reviewed_opener_is_detected(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rotate: + req.remove_header("Authorization") + req.add_header("Authorization", f"Bearer {api_key}") + opener = urllib.request.build_opener(SafeRedirectHandler()) + return opener.open(req, timeout=5) + return None +""" + assert len(_scan_source(tmp_path, source)) == 1 + + def test_remove_without_restoration_stays_non_credentialed(tmp_path): source = """\ def deliver(url, api_key, disable): @@ -90,3 +110,83 @@ def deliver(url, api_key, rotate): return None """ assert not _scan_source(tmp_path, source) + + +def test_endpoint_replacement_before_request_breaks_validated_destination(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + endpoint = "https://fixed.example/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rotate: + req.remove_header("Authorization") + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_request_replacement_before_restored_dispatch_breaks_bearer_path(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rotate: + req.remove_header("Authorization") + req.add_header("Authorization", f"Bearer {api_key}") + req = urllib.request.Request("https://fixed.example/api/v1/scans") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_unconditional_return_before_restored_dispatch_is_not_reachable(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rotate: + req.remove_header("Authorization") + req.add_header("Authorization", f"Bearer {api_key}") + return None + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_self_derived_endpoint_before_request_retains_vulnerable_flow(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + endpoint = endpoint + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rotate: + req.remove_header("Authorization") + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert len(_scan_source(tmp_path, source)) == 1 From 63dd65c3cb8c774082d197a5df017f39f9abd264 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:57:39 +0900 Subject: [PATCH 033/105] docs: trace restored bearer provenance boundaries --- docs/TRACEABILITY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index d9d24063..433340e7 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -59,6 +59,8 @@ Request construction is destination- and credential-bound rather than token-text Credential-removal barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")`, `headers.pop("Authorization", ...)`, or `del headers["Authorization"]` before dispatch breaks bearer provenance; removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. The production review-boundary regressions preserve both cases together with first-positional and keyword-URL positives so later regex changes cannot recover recall by reintroducing these HIGH false positives. +Restoring a Bearer credential after one of those explicit removals re-establishes the defect only when the same validated destination and request remain live through the restoration and reviewed re-resolving sink. The supplemental regression path therefore treats direct `urllib.request.urlopen(req)` and `urllib.request.build_opener(SafeRedirectHandler()).open(req)` as positives after a Bearer restoration, but breaks provenance when the validated URL/endpoint is replaced before `Request` construction, the tracked request is replaced before dispatch, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so the false-positive barriers do not become silent false-negative sanitizers. + Simple self-derived assignments preserve the tracked path when an executable tracked identifier appears before any string literal or comment on the right-hand side, including `endpoint = endpoint + ...`, URL-derived endpoint updates, and `req = req`. An assignment where the tracked name occurs only as quoted data, such as `endpoint = choose("endpoint")` or `req = choose("req")`, is a provenance break and must not create a HIGH finding. More general wrapper/helper-mediated value flow that places a literal before the tracked identifier is outside this bounded regex detector and requires separate executable evidence rather than speculative flow inference. Paired production `_scan_file` regressions keep both the self-derived positives and quoted-name replacement negatives stable. The detector deliberately does not claim general interprocedural or cross-library DNS-rebinding analysis. Alternative HTTP clients, helper/cross-file flows, differently named validation boundaries, richer credential construction expressions, and custom transports whose actual socket connection is independently pinned require separate evidence. Do not infer scanner maturity from the already-merged runtime repair; promotion requires the detector and its regression corpus on protected `develop` plus fresh protected-head required evidence. From 3ba1eaa47a76f161448f2498e32690d3be6a47e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:02:54 +0900 Subject: [PATCH 034/105] fix(sast): bind restored bearer headers to Request --- scanner/rules/ssrf_bearer_restore.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index 17540fd1..bb382b41 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[^\n\#]*\bheaders[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?=(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$).){0,1500}?^(?![ \t]*\#)[^\n]*\bheaders[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?=(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$).){0,1500}?^(?P=argindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety preflight even though an Authorization header was removed and then restored. @@ -11,6 +11,6 @@ rules: [CWE-918 - Server-Side Request Forgery] severity: HIGH languages: [python] - prefilter: [_is_safe_url, urllib.request.Request, Authorization, Bearer, urllib.request.urlopen] + prefilter: [_is_safe_url, urllib.request.Request, Authorization, Bearer] cwe: [CWE-367, CWE-918] owasp: [A10:2021] From 09f0e959a77a607b1f2f443fc267352aae05d0d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:03:35 +0900 Subject: [PATCH 035/105] fix(sast): derive restored bearer credential at mutation --- scanner/rules/ssrf_bearer_restore.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index bb382b41..45f824b6 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?=(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$).){0,1500}?^(?P=argindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety preflight even though an Authorization header was removed and then restored. From dde31d5beb297f9413938774e59e1ee7da7465f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:04:09 +0900 Subject: [PATCH 036/105] test(sast): prove late bearer restoration supplies credential --- ...est_bearer_dns_toctou_restored_credentials.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_bearer_dns_toctou_restored_credentials.py b/tests/test_bearer_dns_toctou_restored_credentials.py index 67ddd521..a1f3c6ff 100644 --- a/tests/test_bearer_dns_toctou_restored_credentials.py +++ b/tests/test_bearer_dns_toctou_restored_credentials.py @@ -190,3 +190,19 @@ def deliver(url, api_key, rotate): return None """ assert len(_scan_source(tmp_path, source)) == 1 + + +def test_late_bearer_restore_is_the_actual_credential_source(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, data=b"{}") + if rotate: + req.remove_header("Authorization") + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert len(_scan_source(tmp_path, source)) == 1 From 5c9e86a181bd0a394a7bc5b7434c527b0f1cdc13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:04:40 +0900 Subject: [PATCH 037/105] docs: clarify restored bearer credential provenance --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 433340e7..6f79b26a 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -59,7 +59,7 @@ Request construction is destination- and credential-bound rather than token-text Credential-removal barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")`, `headers.pop("Authorization", ...)`, or `del headers["Authorization"]` before dispatch breaks bearer provenance; removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. The production review-boundary regressions preserve both cases together with first-positional and keyword-URL positives so later regex changes cannot recover recall by reintroducing these HIGH false positives. -Restoring a Bearer credential after one of those explicit removals re-establishes the defect only when the same validated destination and request remain live through the restoration and reviewed re-resolving sink. The supplemental regression path therefore treats direct `urllib.request.urlopen(req)` and `urllib.request.build_opener(SafeRedirectHandler()).open(req)` as positives after a Bearer restoration, but breaks provenance when the validated URL/endpoint is replaced before `Request` construction, the tracked request is replaced before dispatch, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so the false-positive barriers do not become silent false-negative sanitizers. +Restoring a Bearer credential after one of those explicit removals re-establishes the defect only when the same validated destination and request remain live through the restoration and reviewed re-resolving sink. The supplemental regression path therefore treats direct `urllib.request.urlopen(req)` and `urllib.request.build_opener(SafeRedirectHandler()).open(req)` as positives after a Bearer restoration, but breaks provenance when the validated URL/endpoint is replaced before `Request` construction, the tracked request is replaced before dispatch, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so the false-positive barriers do not become silent false-negative sanitizers. In this restoration path, the post-removal `add_header`/`add_unredirected_header` or `headers["Authorization"] = "Bearer ..."` mutation is the authoritative credential source; the earlier `Request` need not already contain a Bearer header, and Bearer-looking text nested in request data is not used as header evidence. Simple self-derived assignments preserve the tracked path when an executable tracked identifier appears before any string literal or comment on the right-hand side, including `endpoint = endpoint + ...`, URL-derived endpoint updates, and `req = req`. An assignment where the tracked name occurs only as quoted data, such as `endpoint = choose("endpoint")` or `req = choose("req")`, is a provenance break and must not create a HIGH finding. More general wrapper/helper-mediated value flow that places a literal before the tracked identifier is outside this bounded regex detector and requires separate executable evidence rather than speculative flow inference. Paired production `_scan_file` regressions keep both the self-derived positives and quoted-name replacement negatives stable. From 1db03cf742319321e6ae08cff9d8abf127883359 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:08:20 +0900 Subject: [PATCH 038/105] fix(sast): bind bearer headers and nested request flow --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 56c5308b..17053945 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[^\n\#]*\bheaders[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?=(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$).){0,1500}?^(?![ \t]*\#)[^\n]*\bheaders[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?![ \t]*\#)[^\n]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)\#[^\n]*\n)|(?:^[ \t]*\n)){0,4}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From 495319f3d3b8c03a413385c7145466337536df36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:10:17 +0900 Subject: [PATCH 039/105] test(sast): lock request-header and branch replacement boundaries --- ...est_bearer_dns_toctou_review_boundaries.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/test_bearer_dns_toctou_review_boundaries.py b/tests/test_bearer_dns_toctou_review_boundaries.py index 51726145..3671233f 100644 --- a/tests/test_bearer_dns_toctou_review_boundaries.py +++ b/tests/test_bearer_dns_toctou_review_boundaries.py @@ -49,6 +49,21 @@ def deliver(url, api_key): assert not _scan_source(tmp_path, source) +def test_nested_headers_inside_data_are_not_request_header_evidence(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + data=encode(headers={"Authorization": f"Bearer {api_key}"}), + ) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + def test_fixed_request_url_cannot_borrow_endpoint_from_other_header(tmp_path): source = """\ def deliver(url, api_key): @@ -172,6 +187,61 @@ def deliver(url, api_key, disable): assert len(_scan_source(tmp_path, source)) == 1 +def test_nested_same_branch_unauthenticated_request_replacement_breaks_path(tmp_path): + source = """\ +def deliver(url, api_key, replace): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if replace: + req = urllib.request.Request("https://fixed.example/api/v1/scans") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_opposite_branch_request_replacement_does_not_sanitize_dispatch(tmp_path): + source = """\ +def deliver(url, api_key, replace): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if replace: + req = urllib.request.Request("https://fixed.example/api/v1/scans") + else: + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert len(_scan_source(tmp_path, source)) == 1 + + +def test_nested_same_branch_bearer_request_replacement_remains_vulnerable(tmp_path): + source = """\ +def deliver(url, api_key, replace): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if replace: + req = urllib.request.Request(endpoint, headers={"Authorization": f"Bearer {api_key}"}) + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert len(_scan_source(tmp_path, source)) == 1 + + def test_first_positional_request_url_still_detected(tmp_path): source = """\ def deliver(url, api_key): From d3f682ba3720eb19fec387876ab7dcbbd2c0cc92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:10:47 +0900 Subject: [PATCH 040/105] docs: trace request header and branch replacement boundaries --- docs/TRACEABILITY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 6f79b26a..80cc1b77 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -55,15 +55,15 @@ Security issue #892 and merged PR #898 establish the runtime defect and repair: PR #1080 is the integration record for the independent scanner obligation. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` carrying an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. The credential source accepts the reviewed f-string form and equivalent literal-prefix string concatenation, and ordinary one-line/multiline request syntax or trailing executable-line comments do not erase the path. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector class. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. -Request construction is destination- and credential-bound rather than token-text-bound. The tracked endpoint must be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. Likewise, `Authorization: Bearer ...` counts only as request-header evidence, not when identical-looking text appears in `data=` or another request-body value. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. +Request construction is destination- and credential-bound rather than token-text-bound. The tracked endpoint must be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. For the primary Request-credential path, `Authorization: Bearer ...` counts only when it is in the supported direct `headers=` argument at Request-call argument indentation; a nested `headers=` expression inside `data=` or another argument is not HTTP-header evidence. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. -Credential-removal barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")`, `headers.pop("Authorization", ...)`, or `del headers["Authorization"]` before dispatch breaks bearer provenance; removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. The production review-boundary regressions preserve both cases together with first-positional and keyword-URL positives so later regex changes cannot recover recall by reintroducing these HIGH false positives. +Credential-removal and request-replacement barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")`, `headers.pop("Authorization", ...)`, or `del headers["Authorization"]` before dispatch breaks bearer provenance; removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. Likewise, a same-branch replacement of the tracked request with an obviously unauthenticated `urllib.request.Request(...)` before the reviewed sink breaks the tracked credential path, while an opposite-branch replacement cannot sanitize a sibling branch that still dispatches the original Bearer request. A replacement that visibly carries Bearer authorization remains positive. The production review-boundary regressions preserve these paired cases together with first-positional and keyword-URL positives so later regex changes cannot recover precision by creating silent false negatives. Restoring a Bearer credential after one of those explicit removals re-establishes the defect only when the same validated destination and request remain live through the restoration and reviewed re-resolving sink. The supplemental regression path therefore treats direct `urllib.request.urlopen(req)` and `urllib.request.build_opener(SafeRedirectHandler()).open(req)` as positives after a Bearer restoration, but breaks provenance when the validated URL/endpoint is replaced before `Request` construction, the tracked request is replaced before dispatch, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so the false-positive barriers do not become silent false-negative sanitizers. In this restoration path, the post-removal `add_header`/`add_unredirected_header` or `headers["Authorization"] = "Bearer ..."` mutation is the authoritative credential source; the earlier `Request` need not already contain a Bearer header, and Bearer-looking text nested in request data is not used as header evidence. Simple self-derived assignments preserve the tracked path when an executable tracked identifier appears before any string literal or comment on the right-hand side, including `endpoint = endpoint + ...`, URL-derived endpoint updates, and `req = req`. An assignment where the tracked name occurs only as quoted data, such as `endpoint = choose("endpoint")` or `req = choose("req")`, is a provenance break and must not create a HIGH finding. More general wrapper/helper-mediated value flow that places a literal before the tracked identifier is outside this bounded regex detector and requires separate executable evidence rather than speculative flow inference. Paired production `_scan_file` regressions keep both the self-derived positives and quoted-name replacement negatives stable. -The detector deliberately does not claim general interprocedural or cross-library DNS-rebinding analysis. Alternative HTTP clients, helper/cross-file flows, differently named validation boundaries, richer credential construction expressions, and custom transports whose actual socket connection is independently pinned require separate evidence. Do not infer scanner maturity from the already-merged runtime repair; promotion requires the detector and its regression corpus on protected `develop` plus fresh protected-head required evidence. +The detector deliberately does not claim general interprocedural or cross-library DNS-rebinding analysis. Alternative HTTP clients, helper/cross-file flows, differently named validation boundaries, richer credential construction expressions, arbitrary Request argument ordering beyond the supported direct-header layouts, and custom transports whose actual socket connection is independently pinned require separate evidence. Do not infer scanner maturity from the already-merged runtime repair; promotion requires the detector and its regression corpus on protected `develop` plus fresh protected-head required evidence. ## Standards/research From 3f3834256e1f014d1675881a157add75e40a549c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:19:39 +0900 Subject: [PATCH 041/105] fix(sast): model bearer header mutations as a unique subrule --- scanner/rules/ssrf_bearer_restore.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index 45f824b6..a70f0d95 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,12 +1,12 @@ rules: - - id: python-bearer-preflight-dns-toctou + - id: python-bearer-preflight-dns-toctou-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?:(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety - preflight even though an Authorization header was removed and then restored. - Restoring the Bearer credential re-establishes the credential-bearing DNS - re-resolution path; connect through the already-validated address set instead. + preflight after Bearer authorization is added or restored on the live request. + The credential-bearing dispatch can repeat DNS resolution after validation; + connect through the already-validated address set instead. [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] [CWE-918 - Server-Side Request Forgery] severity: HIGH From d93c218d534a8536cedf5b3ec7222b69786eab19 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:20:06 +0900 Subject: [PATCH 042/105] test(sast): cover direct and restored bearer mutations --- ..._bearer_dns_toctou_restored_credentials.py | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/tests/test_bearer_dns_toctou_restored_credentials.py b/tests/test_bearer_dns_toctou_restored_credentials.py index a1f3c6ff..a6a7dcad 100644 --- a/tests/test_bearer_dns_toctou_restored_credentials.py +++ b/tests/test_bearer_dns_toctou_restored_credentials.py @@ -1,10 +1,13 @@ -"""Regression boundaries for restored Bearer credentials after DNS preflight.""" +"""Regression boundaries for Bearer credentials added after DNS preflight.""" from pathlib import Path from scanner.cli.appguardrail import _scan_file -_RULE_ID = "python-bearer-preflight-dns-toctou" +_RULE_IDS = { + "python-bearer-preflight-dns-toctou", + "python-bearer-preflight-dns-toctou-header-mutation", +} def _scan_source(tmp_path: Path, source: str): @@ -13,10 +16,53 @@ def _scan_source(tmp_path: Path, source: str): return [ finding for finding in _scan_file(source_file, tmp_path) - if finding["rule_id"] == _RULE_ID + if finding["rule_id"] in _RULE_IDS ] +def test_direct_add_header_creates_bearer_path(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, data=b"{}") + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "python-bearer-preflight-dns-toctou-header-mutation" + + +def test_direct_header_assignment_creates_bearer_path(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + req.headers["Authorization"] = "Bearer " + api_key + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "python-bearer-preflight-dns-toctou-header-mutation" + + +def test_direct_non_bearer_header_does_not_create_path(tmp_path): + source = """\ +def deliver(url): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + req.add_header("Authorization", "Basic fixed") + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + def test_remove_then_add_header_restores_bearer_path(tmp_path): source = """\ def deliver(url, api_key, rotate): From 01c3fa24e5e1cad4a79e60359f918215abcb7db4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:21:13 +0900 Subject: [PATCH 043/105] fix(sast): preserve POST and nested replacement flow boundaries --- scanner/rules/ssrf.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 17053945..5c4cc37e 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)\#[^\n]*\n)|(?:^[ \t]*\n)){0,4}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b)[^\n]*|(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, @@ -27,4 +27,4 @@ rules: languages: [python] prefilter: [_is_safe_url, urllib.request.Request, Authorization, Bearer] cwe: [CWE-367, CWE-918] - owasp: [A10:2021] + owasp: [A10:2021] \ No newline at end of file From fa2f791522079538e5cc7b498af200a41eaea516 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:21:28 +0900 Subject: [PATCH 044/105] test(sast): lock latest bearer TOCTOU review boundaries --- ...er_dns_toctou_latest_review_regressions.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_latest_review_regressions.py diff --git a/tests/test_bearer_dns_toctou_latest_review_regressions.py b/tests/test_bearer_dns_toctou_latest_review_regressions.py new file mode 100644 index 00000000..baa0e0cd --- /dev/null +++ b/tests/test_bearer_dns_toctou_latest_review_regressions.py @@ -0,0 +1,91 @@ +"""Current-head regressions for the bearer DNS TOCTOU detector family.""" + +from pathlib import Path + +from scanner.cli.appguardrail import SCAN_RULES, _scan_file + +_PRIMARY = "python-bearer-preflight-dns-toctou" +_MUTATION = "python-bearer-preflight-dns-toctou-header-mutation" +_FAMILY = {_PRIMARY, _MUTATION} + + +def _scan_source(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def test_packaged_detector_subrules_have_unique_stable_ids(): + loaded = [rule["id"] for rule in SCAN_RULES if rule["id"] in _FAMILY] + assert loaded.count(_PRIMARY) == 1 + assert loaded.count(_MUTATION) == 1 + + +def test_post_request_with_data_and_method_before_headers_is_detected(tmp_path): + source = """\ +def deliver(url, payload, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + data=payload, + method="POST", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + ) + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert len(findings) == 1 + assert findings[0]["rule_id"] == _PRIMARY + + +def test_nested_fixed_destination_bearer_replacement_breaks_provenance(tmp_path): + source = """\ +def deliver(url, api_key, use_fixed): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if use_fixed: + req = urllib.request.Request( + "https://fixed.example/api/v1/scans", + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source) + + +def test_nested_same_endpoint_bearer_replacement_preserves_provenance(tmp_path): + source = """\ +def deliver(url, api_key, rebuild): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rebuild: + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return urllib.request.urlopen(req, timeout=5) + return None +""" + findings = _scan_source(tmp_path, source) + assert len(findings) == 1 + assert findings[0]["rule_id"] == _PRIMARY From 88786019b98f77e96a4c4adf39f12e794953a425 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:25:27 +0900 Subject: [PATCH 045/105] docs(security): reconcile bearer TOCTOU detector family --- docs/TRACEABILITY.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 80cc1b77..9e1c8255 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -20,7 +20,7 @@ | authenticated workflow-result detector evidence | issue-detection audit workflow evidence | PR #911 active-PR | | automatic scanner detection of unsafe stored-webhook SSRF pattern | built-in `python-stored-ssrf-webhook-url` rule | implemented-main through PR #910 for tested Python `set_webhook` direct and one-hop persistence flows; bounded scope | | bearer-authenticated DNS rebinding prevention | DNS-pinned HTTPS control-plane transport | implemented-main through PR #898 for the reviewed scan-delivery boundary | -| automatic scanner detection of preflight DNS-validation TOCTOU before bearer urllib dispatch | built-in `python-bearer-preflight-dns-toctou` rule | integration evidence: PR #1080; source-backed vulnerable/fixed fixtures and production `_scan_file` regressions must remain with the detector | +| automatic scanner detection of preflight DNS-validation TOCTOU before bearer urllib dispatch | built-in `python-bearer-preflight-dns-toctou` and `python-bearer-preflight-dns-toctou-header-mutation` detector family | integration evidence: PR #1080; source-backed vulnerable/fixed fixtures and production `_scan_file` regressions must remain with the detector family | | structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution | ## Promotion rules @@ -53,13 +53,13 @@ Current protected-branch evidence keeps those controls distinct: PR #924 supplie Security issue #892 and merged PR #898 establish the runtime defect and repair: a bearer-authenticated control-plane push first validated a URL, then `urllib` made a second DNS decision during connection; the protected repair uses DNS-pinned HTTPS so the actual connection uses the validated public address set while retaining the hostname for TLS identity verification. -PR #1080 is the integration record for the independent scanner obligation. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` carrying an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. The credential source accepts the reviewed f-string form and equivalent literal-prefix string concatenation, and ordinary one-line/multiline request syntax or trailing executable-line comments do not erase the path. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector class. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. +PR #1080 is the integration record for the independent scanner obligation. The detector family keeps two executable rule identities rather than loading two regexes under one ambiguous ID. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. -Request construction is destination- and credential-bound rather than token-text-bound. The tracked endpoint must be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. For the primary Request-credential path, `Authorization: Bearer ...` counts only when it is in the supported direct `headers=` argument at Request-call argument indentation; a nested `headers=` expression inside `data=` or another argument is not HTTP-header evidence. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. +Request construction is destination- and credential-bound rather than token-text-bound. The tracked endpoint must be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. For the primary Request-credential path, `Authorization: Bearer ...` counts only when it is in the supported direct `headers=` argument at Request-call argument indentation; a nested `headers=` expression inside `data=` or another argument is not HTTP-header evidence. Ordinary direct Request keyword arguments such as `data=` and `method=` may appear between the tracked URL argument and the direct `headers=` argument without breaking the path. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. -Credential-removal and request-replacement barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")`, `headers.pop("Authorization", ...)`, or `del headers["Authorization"]` before dispatch breaks bearer provenance; removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. Likewise, a same-branch replacement of the tracked request with an obviously unauthenticated `urllib.request.Request(...)` before the reviewed sink breaks the tracked credential path, while an opposite-branch replacement cannot sanitize a sibling branch that still dispatches the original Bearer request. A replacement that visibly carries Bearer authorization remains positive. The production review-boundary regressions preserve these paired cases together with first-positional and keyword-URL positives so later regex changes cannot recover precision by creating silent false negatives. +Credential-removal and request-replacement barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")`, `headers.pop("Authorization", ...)`, or `del headers["Authorization"]` before dispatch breaks primary-rule bearer provenance unless a supported later Bearer mutation re-establishes credential flow; removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. Likewise, a same-branch replacement of the tracked request breaks the original path whenever the replacement destination is not the tracked endpoint, regardless of whether the replacement itself carries Bearer credentials. A rebuild that still targets the tracked endpoint remains detectable when credential provenance also remains, while an unauthenticated same-endpoint rebuild is a credential break. An opposite-branch replacement cannot sanitize a sibling branch that still dispatches the original Bearer request. The production review-boundary regressions preserve these paired cases together with first-positional and keyword-URL positives so later regex changes cannot recover precision by creating silent false negatives. -Restoring a Bearer credential after one of those explicit removals re-establishes the defect only when the same validated destination and request remain live through the restoration and reviewed re-resolving sink. The supplemental regression path therefore treats direct `urllib.request.urlopen(req)` and `urllib.request.build_opener(SafeRedirectHandler()).open(req)` as positives after a Bearer restoration, but breaks provenance when the validated URL/endpoint is replaced before `Request` construction, the tracked request is replaced before dispatch, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so the false-positive barriers do not become silent false-negative sanitizers. In this restoration path, the post-removal `add_header`/`add_unredirected_header` or `headers["Authorization"] = "Bearer ..."` mutation is the authoritative credential source; the earlier `Request` need not already contain a Bearer header, and Bearer-looking text nested in request data is not used as header evidence. +Post-construction Bearer mutation is an independent credential source for the header-mutation subrule and does not require a preceding removal. `add_header("Authorization", "Bearer ...")`, `add_unredirected_header(...)`, or `headers["Authorization"] = "Bearer ..."` can therefore establish the credential-bearing DNS-re-resolution path on an initially unauthenticated tracked Request; the same forms also restore the path after an explicit removal. The mutation path still requires the validated destination and request identity to remain live through the reviewed re-resolving sink, and it breaks provenance when the validated URL/endpoint is replaced before Request construction, the tracked request is replaced before dispatch, a non-Bearer authorization is used instead, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so false-positive barriers do not become silent false-negative sanitizers. Bearer-looking text nested in request data is not used as HTTP-header evidence. Simple self-derived assignments preserve the tracked path when an executable tracked identifier appears before any string literal or comment on the right-hand side, including `endpoint = endpoint + ...`, URL-derived endpoint updates, and `req = req`. An assignment where the tracked name occurs only as quoted data, such as `endpoint = choose("endpoint")` or `req = choose("req")`, is a provenance break and must not create a HIGH finding. More general wrapper/helper-mediated value flow that places a literal before the tracked identifier is outside this bounded regex detector and requires separate executable evidence rather than speculative flow inference. Paired production `_scan_file` regressions keep both the self-derived positives and quoted-name replacement negatives stable. From f7830e4bb3a278ade956a0c781c8cf82d178f592 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:58:07 +0900 Subject: [PATCH 046/105] fix(sast): make bearer credential sources exclusive --- scanner/rules/ssrf_bearer_restore.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index a70f0d95..3acd79e2 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?:(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety preflight after Bearer authorization is added or restored on the live request. From eb3d97035f08094f9eae6a623963bc1d74b1096b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:58:28 +0900 Subject: [PATCH 047/105] test(sast): enforce one bearer TOCTOU family finding --- tests/test_bearer_dns_toctou_family_dedup.py | 85 ++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_family_dedup.py diff --git a/tests/test_bearer_dns_toctou_family_dedup.py b/tests/test_bearer_dns_toctou_family_dedup.py new file mode 100644 index 00000000..1fb63942 --- /dev/null +++ b/tests/test_bearer_dns_toctou_family_dedup.py @@ -0,0 +1,85 @@ +"""Family-level regression boundaries for Bearer DNS TOCTOU credential sources.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_PRIMARY = "python-bearer-preflight-dns-toctou" +_MUTATION = "python-bearer-preflight-dns-toctou-header-mutation" +_FAMILY = {_PRIMARY, _MUTATION} + + +def _family_findings(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def test_initial_bearer_then_add_header_emits_one_primary_finding(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + +def test_initial_bearer_then_direct_header_update_emits_one_primary_finding(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + req.headers["Authorization"] = "Bearer " + api_key + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + +def test_initial_bearer_remove_then_restore_uses_mutation_finding(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + req.remove_header("Authorization") + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_MUTATION] + + +def test_unauthenticated_request_then_bearer_mutation_stays_detected(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, data=b"{}") + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_MUTATION] From db69ba06d625432e211f597d479c263021d59072 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:00:03 +0900 Subject: [PATCH 048/105] fix(sast): preserve direct bearer spacing boundary --- scanner/rules/ssrf_bearer_restore.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index 3acd79e2..ca776a75 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety preflight after Bearer authorization is added or restored on the live request. From 4f7ff692c769c7f8e3a74bb8d3f7d2761402d6e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:00:25 +0900 Subject: [PATCH 049/105] test(sast): cover spaced one-line bearer overlap --- tests/test_bearer_dns_toctou_family_dedup.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_bearer_dns_toctou_family_dedup.py b/tests/test_bearer_dns_toctou_family_dedup.py index 1fb63942..0971fb2c 100644 --- a/tests/test_bearer_dns_toctou_family_dedup.py +++ b/tests/test_bearer_dns_toctou_family_dedup.py @@ -53,6 +53,20 @@ def deliver(url, api_key): assert [finding["rule_id"] for finding in findings] == [_PRIMARY] +def test_spaced_one_line_initial_bearer_update_emits_one_primary_finding(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( endpoint, headers={"Authorization": f"Bearer {api_key}"}) + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + def test_initial_bearer_remove_then_restore_uses_mutation_finding(tmp_path): source = """\ def deliver(url, api_key): From eaea64278e9ed9e5d543d3c1a017be6defc6b4ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:57:15 +0900 Subject: [PATCH 050/105] test(sast): cover one-line bearer Request kwargs --- ...bearer_dns_toctou_one_line_request_args.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_one_line_request_args.py diff --git a/tests/test_bearer_dns_toctou_one_line_request_args.py b/tests/test_bearer_dns_toctou_one_line_request_args.py new file mode 100644 index 00000000..6c724054 --- /dev/null +++ b/tests/test_bearer_dns_toctou_one_line_request_args.py @@ -0,0 +1,46 @@ +"""One-line Request argument-order regressions for Bearer DNS TOCTOU detection.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_PRIMARY = "python-bearer-preflight-dns-toctou" +_MUTATION = "python-bearer-preflight-dns-toctou-header-mutation" +_FAMILY = {_PRIMARY, _MUTATION} + + +def _family_findings(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def test_one_line_request_with_data_and_method_before_headers_is_detected(tmp_path): + source = """\ +def deliver(url, payload, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, data=payload, method="POST", headers={"Authorization": f"Bearer {api_key}"}) + return urllib.request.urlopen(req, timeout=5) +""" + + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + +def test_nested_headers_inside_data_are_not_request_header_evidence(tmp_path): + source = """\ +def deliver(url, payload, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, data=encode(headers={"Authorization": f"Bearer {api_key}"})) + return urllib.request.urlopen(req, timeout=5) +""" + + assert _family_findings(tmp_path, source) == [] From 661eded0ccfaa90e29a5ca1dd0e6cfe4779214a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:58:21 +0900 Subject: [PATCH 051/105] fix(sast): detect one-line bearer Request kwargs --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 5c4cc37e..4ce7588f 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b)[^\n]*|(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b)[^\n]*|(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From ee0b750e74eae4b012fdc5d61656265dfdeb4941 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:59:13 +0900 Subject: [PATCH 052/105] test(sast): keep bearer removal branch-local --- tests/test_bearer_dns_toctou_family_dedup.py | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_bearer_dns_toctou_family_dedup.py b/tests/test_bearer_dns_toctou_family_dedup.py index 0971fb2c..816c24b6 100644 --- a/tests/test_bearer_dns_toctou_family_dedup.py +++ b/tests/test_bearer_dns_toctou_family_dedup.py @@ -97,3 +97,24 @@ def deliver(url, api_key): """ findings = _family_findings(tmp_path, source) assert [finding["rule_id"] for finding in findings] == [_MUTATION] + + +def test_removal_in_if_does_not_combine_with_else_bearer_update(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) + if rotate: + req.remove_header("Authorization") + else: + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) + return None +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] From 7e611533bfc27bc8a6157966d27f6ee03a7c103a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:59:51 +0900 Subject: [PATCH 053/105] fix(sast): keep bearer restore evidence branch-local --- scanner/rules/ssrf_bearer_restore.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index ca776a75..9f3ea3ce 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety preflight after Bearer authorization is added or restored on the live request. From 3bdf6a49a1b38e650223aa11a07087c9f34c5e7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:01:19 +0900 Subject: [PATCH 054/105] test(sast): cover nested bearer mutation paths --- .../test_bearer_dns_toctou_mutation_paths.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_mutation_paths.py diff --git a/tests/test_bearer_dns_toctou_mutation_paths.py b/tests/test_bearer_dns_toctou_mutation_paths.py new file mode 100644 index 00000000..a1451f9f --- /dev/null +++ b/tests/test_bearer_dns_toctou_mutation_paths.py @@ -0,0 +1,63 @@ +"""Control-flow regressions for post-construction Bearer mutation paths.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_RULE_ID = "python-bearer-preflight-dns-toctou-header-mutation" + + +def _mutation_findings(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] == _RULE_ID + ] + + +def test_nested_bearer_mutation_can_fall_through_to_outer_dispatch(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + if rotate: + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_mutation_findings(tmp_path, source)) == 1 + + +def test_nested_bearer_mutation_can_reach_deeper_dispatch(tmp_path): + source = """\ +def deliver(url, api_key, rotate, send): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + if rotate: + req.add_header("Authorization", f"Bearer {api_key}") + if send: + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert len(_mutation_findings(tmp_path, source)) == 1 + + +def test_opposite_branch_dispatch_does_not_borrow_bearer_mutation(tmp_path): + source = """\ +def deliver(url, api_key, rotate): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + if rotate: + req.add_header("Authorization", f"Bearer {api_key}") + else: + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert _mutation_findings(tmp_path, source) == [] From 04c59f2fd0f6f6af9ec3d5aa1891d41e9ca7fa9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:02:09 +0900 Subject: [PATCH 055/105] fix(sast): follow reachable nested bearer dispatches --- scanner/rules/ssrf_bearer_restore.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index 9f3ea3ce..3e681906 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=flowindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=flowindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P(?P=bodyindent)[ \t]*)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=openerindent)(?:return|raise)\b)(?!^(?P=openerindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=openerindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety preflight after Bearer authorization is added or restored on the live request. From 4564fef6f1fc91a7376f3bbd1202f8785777e692 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:02:37 +0900 Subject: [PATCH 056/105] ci: verify packaged rules on Python 3.12 --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8ce929df..89b68bbd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.11', '3.13'] + python-version: ['3.11', '3.12', '3.13'] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From 40da1f58b4499a4ebf238385a1d2a8cebb7c445f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:15:19 +0900 Subject: [PATCH 057/105] fix(sast): track final bearer credential state --- scanner/rules/ssrf_bearer_restore.yml | 4 +- ...rer_dns_toctou_current_head_regressions.py | 143 ++++++++++++++++++ 2 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 tests/test_bearer_dns_toctou_current_head_regressions.py diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index 3e681906..fde433bf 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?(?:^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P(?P=bodyindent)[ \t]*)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=openerindent)(?:return|raise)\b)(?!^(?P=openerindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=openerindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=flowindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]+)).){0,1500}?(?:^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P(?P=bodyindent)[ \t]*)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=openerindent)(?:return|raise)\b)(?!^(?P=openerindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=openerindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety preflight after Bearer authorization is added or restored on the live request. @@ -11,6 +11,6 @@ rules: [CWE-918 - Server-Side Request Forgery] severity: HIGH languages: [python] - prefilter: [_is_safe_url, urllib.request.Request, Authorization, Bearer] + prefilter: [_is_safe_url, urllib.request.Request] cwe: [CWE-367, CWE-918] owasp: [A10:2021] diff --git a/tests/test_bearer_dns_toctou_current_head_regressions.py b/tests/test_bearer_dns_toctou_current_head_regressions.py new file mode 100644 index 00000000..27a044f7 --- /dev/null +++ b/tests/test_bearer_dns_toctou_current_head_regressions.py @@ -0,0 +1,143 @@ +"""Current-head regression boundaries for Bearer DNS TOCTOU credential state.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_PRIMARY = "python-bearer-preflight-dns-toctou" +_MUTATION = "python-bearer-preflight-dns-toctou-header-mutation" +_FAMILY = {_PRIMARY, _MUTATION} + + +def _family_findings(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def test_bearer_mutation_then_removal_is_not_credentialed_at_dispatch(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + req.add_header("Authorization", f"Bearer {api_key}") + req.remove_header("Authorization") + return urllib.request.urlopen(req) +""" + assert not _family_findings(tmp_path, source) + + +def test_bearer_mutation_then_non_bearer_overwrite_is_not_credentialed(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + req.add_header("Authorization", f"Bearer {api_key}") + req.headers["Authorization"] = "Basic fixed" + return urllib.request.urlopen(req) +""" + assert not _family_findings(tmp_path, source) + + +def test_last_bearer_mutation_after_removal_restores_vulnerable_dispatch(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + req.add_header("Authorization", f"Bearer {api_key}") + req.remove_header("Authorization") + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_MUTATION] + + +def test_keyword_urlopen_dispatch_is_detected(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(url=req) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_MUTATION] + + +def test_keyword_reviewed_opener_dispatch_is_detected(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + req.add_header("Authorization", f"Bearer {api_key}") + opener = urllib.request.build_opener(SafeRedirectHandler()) + return opener.open(fullurl=req) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_MUTATION] + + +def test_mixed_case_bearer_mutation_reaches_case_insensitive_rule(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + req.add_header("authorization", f"bEaReR {api_key}") + return urllib.request.urlopen(req) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_MUTATION] + + +def test_initial_bearer_after_post_kwargs_stays_owned_by_primary_rule(tmp_path): + source = """\ +def deliver(url, api_key, payload): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + data=payload, + method="POST", + headers={"Authorization": f"Bearer {api_key}"}, + ) + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + +def test_opposite_branch_removal_does_not_sanitize_bearer_dispatch_branch(tmp_path): + source = """\ +def deliver(url, api_key, refresh): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + if refresh: + req.remove_header("Authorization") + else: + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req) + return None +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_MUTATION] From 46bdbcd7ab35f83753cc56352108b194dfcb4327 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:21:27 +0900 Subject: [PATCH 058/105] fix(sast): close primary bearer provenance gaps --- scanner/rules/ssrf.yml | 4 +- ..._bearer_dns_toctou_primary_current_head.py | 99 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 tests/test_bearer_dns_toctou_primary_current_head.py diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 4ce7588f..b987fa70 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b)[^\n]*|(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b)[^\n]*|(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, @@ -25,6 +25,6 @@ rules: [CWE-918 - Server-Side Request Forgery] severity: HIGH languages: [python] - prefilter: [_is_safe_url, urllib.request.Request, Authorization, Bearer] + prefilter: [_is_safe_url, urllib.request.Request] cwe: [CWE-367, CWE-918] owasp: [A10:2021] \ No newline at end of file diff --git a/tests/test_bearer_dns_toctou_primary_current_head.py b/tests/test_bearer_dns_toctou_primary_current_head.py new file mode 100644 index 00000000..f6fd750b --- /dev/null +++ b/tests/test_bearer_dns_toctou_primary_current_head.py @@ -0,0 +1,99 @@ +"""Current-head regression boundaries for constructor-supplied Bearer DNS TOCTOU.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_PRIMARY = "python-bearer-preflight-dns-toctou" +_MUTATION = "python-bearer-preflight-dns-toctou-header-mutation" +_FAMILY = {_PRIMARY, _MUTATION} + + +def _family_findings(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def test_mixed_case_constructor_bearer_reaches_primary_rule(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, headers={"authorization": f"bEaReR {api_key}"}) + return urllib.request.urlopen(req) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + +def test_primary_keyword_urlopen_dispatch_is_detected(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, headers={"Authorization": f"Bearer {api_key}"}) + return urllib.request.urlopen(url=req) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + +def test_primary_keyword_reviewed_opener_dispatch_is_detected(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, headers={"Authorization": f"Bearer {api_key}"}) + opener = urllib.request.build_opener(SafeRedirectHandler()) + return opener.open(fullurl=req) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + +def test_static_non_bearer_overwrite_kills_primary_credential_state(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, headers={"Authorization": f"Bearer {api_key}"}) + req.headers["Authorization"] = "Basic fixed" + return urllib.request.urlopen(req) +""" + assert not _family_findings(tmp_path, source) + + +def test_static_non_bearer_add_header_kills_primary_credential_state(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, headers={"Authorization": f"Bearer {api_key}"}) + req.add_header("Authorization", "Basic fixed") + return urllib.request.urlopen(req) +""" + assert not _family_findings(tmp_path, source) + + +def test_later_bearer_update_preserves_primary_credential_state(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint, headers={"Authorization": f"Bearer {api_key}"}) + req.headers["Authorization"] = f"Bearer {api_key}" + return urllib.request.urlopen(req) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] From 831725e951e66b8478bab5fd44fbe33a6a5fa363 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:58:13 +0900 Subject: [PATCH 059/105] fix(sast): detect multiline Bearer TOCTOU syntax --- scanner/rules/ssrf_bearer_multiline.yml | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 scanner/rules/ssrf_bearer_multiline.yml diff --git a/scanner/rules/ssrf_bearer_multiline.yml b/scanner/rules/ssrf_bearer_multiline.yml new file mode 100644 index 00000000..8f817645 --- /dev/null +++ b/scanner/rules/ssrf_bearer_multiline.yml @@ -0,0 +1,27 @@ +rules: + - id: python-bearer-preflight-dns-toctou-multiline-constructor + patterns: + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1800}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n\#]{0,600}?headers[ \t]*=[ \t]*\{[^\n\#}]{0,500}?["\x27]Authorization["\x27][ \t]*:[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])|(?:[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=argindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=argindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,800}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n|(?:\#[^\n]*)?\n)[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1800}?(?:urllib\.request\.urlopen[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=req)\b|(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?(?P=opener)\.open[ \t]*\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + message: | + A Bearer-authenticated urllib request is dispatched after a separate URL safety + preflight, so connection-time DNS resolution can diverge from validation. This + companion rule covers supported multiline constructor formatting. + [CWE-367 - Time-of-check Time-of-use Race Condition] [CWE-918 - Server-Side Request Forgery] + severity: HIGH + languages: [python] + prefilter: [_is_safe_url, urllib.request.Request] + cwe: [CWE-367, CWE-918] + owasp: [A10:2021] + - id: python-bearer-preflight-dns-toctou-multiline-header-mutation + patterns: + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1800}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1200}?^(?P[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*(?:\n[ \t]*)?\][ \t]*=[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n)?[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1800}?(?:urllib\.request\.urlopen[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=req)\b|(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?(?P=opener)\.open[ \t]*\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + message: | + A Bearer credential added to a validated urllib request is dispatched through a + resolver-backed transport after the preflight. This companion rule covers supported + multiline header-mutation formatting. + [CWE-367 - Time-of-check Time-of-use Race Condition] [CWE-918 - Server-Side Request Forgery] + severity: HIGH + languages: [python] + prefilter: [_is_safe_url, urllib.request.Request] + cwe: [CWE-367, CWE-918] + owasp: [A10:2021] From 81e8912e1c0c1806745520d50065664ec48e08c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:58:27 +0900 Subject: [PATCH 060/105] test(sast): cover multiline Bearer TOCTOU syntax --- ...bearer_dns_toctou_multiline_regressions.py | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_multiline_regressions.py diff --git a/tests/test_bearer_dns_toctou_multiline_regressions.py b/tests/test_bearer_dns_toctou_multiline_regressions.py new file mode 100644 index 00000000..c2892c81 --- /dev/null +++ b/tests/test_bearer_dns_toctou_multiline_regressions.py @@ -0,0 +1,120 @@ +"""Regression coverage for multiline Bearer DNS-TOCTOU syntax.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_PRIMARY = "python-bearer-preflight-dns-toctou" +_MUTATION = "python-bearer-preflight-dns-toctou-header-mutation" +_MULTILINE_CONSTRUCTOR = "python-bearer-preflight-dns-toctou-multiline-constructor" +_MULTILINE_MUTATION = "python-bearer-preflight-dns-toctou-multiline-header-mutation" +_FAMILY = {_PRIMARY, _MUTATION, _MULTILINE_CONSTRUCTOR, _MULTILINE_MUTATION} + + +def _family_ids(tmp_path: Path, source: str) -> list[str]: + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding["rule_id"] + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def _prefix() -> str: + return """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" +""" + + +def test_wrapped_constructor_bearer_value_is_detected_once(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint, headers={"Authorization": ( + f"Bearer {api_key}" + )}) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_CONSTRUCTOR] + + +def test_multiline_request_headers_value_is_detected_once(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request( + endpoint, + headers={ + "Authorization": ( + f"Bearer {api_key}" + ), + }, + ) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_CONSTRUCTOR] + + +def test_multiline_add_header_is_detected_once(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_MUTATION] + + +def test_multiline_add_unredirected_header_is_detected_once(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint) + req.add_unredirected_header( + "Authorization", + f"Bearer {api_key}", + ) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_MUTATION] + + +def test_multiline_direct_header_assignment_is_detected_once(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint) + req.headers[ + "Authorization" + ] = ( + f"Bearer {api_key}" + ) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_MUTATION] + + +def test_nested_body_headers_are_not_constructor_credentials(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request( + endpoint, + data=encode(headers={ + "Authorization": ( + f"Bearer {api_key}" + ) + }), + ) + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_commented_bearer_value_does_not_authenticate_multiline_mutation(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint) + req.add_header( + "Authorization", + # f"Bearer {api_key}" + "Basic fixed", + ) + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) From 28bc62736f5e31eae653519a2edd30e5707a5a8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:00:12 +0900 Subject: [PATCH 061/105] fix(sast): preserve multiline DNS TOCTOU flow barriers --- scanner/rules/ssrf_bearer_multiline.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scanner/rules/ssrf_bearer_multiline.yml b/scanner/rules/ssrf_bearer_multiline.yml index 8f817645..db34c978 100644 --- a/scanner/rules/ssrf_bearer_multiline.yml +++ b/scanner/rules/ssrf_bearer_multiline.yml @@ -1,11 +1,12 @@ rules: - id: python-bearer-preflight-dns-toctou-multiline-constructor patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1800}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n\#]{0,600}?headers[ \t]*=[ \t]*\{[^\n\#}]{0,500}?["\x27]Authorization["\x27][ \t]*:[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])|(?:[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=argindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=argindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,800}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n|(?:\#[^\n]*)?\n)[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1800}?(?:urllib\.request\.urlopen[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=req)\b|(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?(?P=opener)\.open[ \t]*\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]{0,500}?["\x27]Authorization["\x27][ \t]*:[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=argindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=argindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,800}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n|(?:\#[^\n]*)?\n)[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]\)| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b| ^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' message: | A Bearer-authenticated urllib request is dispatched after a separate URL safety preflight, so connection-time DNS resolution can diverge from validation. This - companion rule covers supported multiline constructor formatting. + companion rule covers supported multiline constructor formatting while preserving + destination, request, credential, and reachability barriers. [CWE-367 - Time-of-check Time-of-use Race Condition] [CWE-918 - Server-Side Request Forgery] severity: HIGH languages: [python] @@ -14,11 +15,12 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou-multiline-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1800}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1200}?^(?P[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*(?:\n[ \t]*)?\][ \t]*=[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n)?[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,1800}?(?:urllib\.request\.urlopen[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=req)\b|(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?(?P=opener)\.open[ \t]*\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))[^\n]*\n){0,60}?^(?P[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*(?:\n[ \t]*)?\][ \t]*=[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n)?[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=mutindent)(?:return|raise)\b)(?!^(?P=mutindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))(?!^(?P=mutindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]\)| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b| ^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' message: | A Bearer credential added to a validated urllib request is dispatched through a resolver-backed transport after the preflight. This companion rule covers supported - multiline header-mutation formatting. + multiline header-mutation formatting while preserving destination, request, + credential, and reachability barriers. [CWE-367 - Time-of-check Time-of-use Race Condition] [CWE-918 - Server-Side Request Forgery] severity: HIGH languages: [python] From 1815b3099113d07bbd3c5fa7222ff4256120d6a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:00:34 +0900 Subject: [PATCH 062/105] test(sast): cover multiline DNS TOCTOU barriers --- ...bearer_dns_toctou_multiline_regressions.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/test_bearer_dns_toctou_multiline_regressions.py b/tests/test_bearer_dns_toctou_multiline_regressions.py index c2892c81..431bd4e5 100644 --- a/tests/test_bearer_dns_toctou_multiline_regressions.py +++ b/tests/test_bearer_dns_toctou_multiline_regressions.py @@ -118,3 +118,134 @@ def test_commented_bearer_value_does_not_authenticate_multiline_mutation(tmp_pat return urllib.request.urlopen(req) """ assert not _family_ids(tmp_path, source) + + +def test_multiline_constructor_stops_at_unrelated_endpoint_replacement(tmp_path): + source = _prefix() + """\ + endpoint = "https://fixed.example/api" + req = urllib.request.Request(endpoint, headers={"Authorization": ( + f"Bearer {api_key}" + )}) + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_constructor_preserves_self_derived_endpoint(tmp_path): + source = _prefix() + """\ + endpoint = endpoint + "/next" + req = urllib.request.Request(endpoint, headers={"Authorization": ( + f"Bearer {api_key}" + )}) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_CONSTRUCTOR] + + +def test_multiline_constructor_stops_at_request_replacement(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint, headers={"Authorization": ( + f"Bearer {api_key}" + )}) + req = urllib.request.Request("https://fixed.example/api") + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_constructor_stops_after_authorization_removal(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint, headers={"Authorization": ( + f"Bearer {api_key}" + )}) + req.remove_header("Authorization") + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_constructor_stops_after_non_bearer_replacement(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint, headers={"Authorization": ( + f"Bearer {api_key}" + )}) + req.add_header("Authorization", "Basic fixed") + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_constructor_stops_at_unreachable_dispatch(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint, headers={"Authorization": ( + f"Bearer {api_key}" + )}) + return None + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_mutation_stops_at_request_replacement(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + req = urllib.request.Request("https://fixed.example/api") + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_mutation_stops_after_authorization_removal(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + req.remove_header("Authorization") + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_mutation_stops_after_non_bearer_replacement(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + req.headers["Authorization"] = "Basic fixed" + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_mutation_stops_at_unreachable_dispatch(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + raise RuntimeError("stop") + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_remove_then_restore_remains_detectable(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request(endpoint) + req.remove_header("Authorization") + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_MUTATION] From c8620cc6e44bc6bd1560ca7b994631203c2c0697 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:04:50 +0900 Subject: [PATCH 063/105] docs(security): trace multiline DNS TOCTOU rules --- docs/TRACEABILITY.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 9e1c8255..ff38a659 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -20,7 +20,7 @@ | authenticated workflow-result detector evidence | issue-detection audit workflow evidence | PR #911 active-PR | | automatic scanner detection of unsafe stored-webhook SSRF pattern | built-in `python-stored-ssrf-webhook-url` rule | implemented-main through PR #910 for tested Python `set_webhook` direct and one-hop persistence flows; bounded scope | | bearer-authenticated DNS rebinding prevention | DNS-pinned HTTPS control-plane transport | implemented-main through PR #898 for the reviewed scan-delivery boundary | -| automatic scanner detection of preflight DNS-validation TOCTOU before bearer urllib dispatch | built-in `python-bearer-preflight-dns-toctou` and `python-bearer-preflight-dns-toctou-header-mutation` detector family | integration evidence: PR #1080; source-backed vulnerable/fixed fixtures and production `_scan_file` regressions must remain with the detector family | +| automatic scanner detection of preflight DNS-validation TOCTOU before bearer urllib dispatch | built-in `python-bearer-preflight-dns-toctou`, `python-bearer-preflight-dns-toctou-header-mutation`, `python-bearer-preflight-dns-toctou-multiline-constructor`, and `python-bearer-preflight-dns-toctou-multiline-header-mutation` detector family | integration evidence: PR #1080; source-backed vulnerable/fixed fixtures and production `_scan_file` regressions must remain with the detector family | | structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution | ## Promotion rules @@ -53,7 +53,9 @@ Current protected-branch evidence keeps those controls distinct: PR #924 supplie Security issue #892 and merged PR #898 establish the runtime defect and repair: a bearer-authenticated control-plane push first validated a URL, then `urllib` made a second DNS decision during connection; the protected repair uses DNS-pinned HTTPS so the actual connection uses the validated public address set while retaining the hostname for TLS identity verification. -PR #1080 is the integration record for the independent scanner obligation. The detector family keeps two executable rule identities rather than loading two regexes under one ambiguous ID. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. +PR #1080 is the integration record for the independent scanner obligation. The detector family keeps four executable rule identities rather than overloading one rule ID or forcing unrelated syntax forms through one expression. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. `python-bearer-preflight-dns-toctou-multiline-constructor` and `python-bearer-preflight-dns-toctou-multiline-header-mutation` cover the corresponding reviewed line-wrapped credential forms without changing the defect identity or broadening the transport boundary. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. + +The multiline companion rules inherit the same destination, request-identity, credential-state, and reachability barriers as their direct-layout family members. Before dispatch, unrelated validated-URL or endpoint replacement, tracked-request replacement, unconditional same-path `return`/`raise`, Authorization removal, or a statically non-Bearer Authorization overwrite terminates the corresponding path; self-derived endpoint/request state remains eligible, and a later supported Bearer restoration can re-establish credential provenance. `tests/test_bearer_dns_toctou_multiline_regressions.py` executes these boundaries through production `_scan_file`, including paired vulnerable, sanitized, dead-sink, self-derived, and remove-then-restore cases. Request construction is destination- and credential-bound rather than token-text-bound. The tracked endpoint must be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. For the primary Request-credential path, `Authorization: Bearer ...` counts only when it is in the supported direct `headers=` argument at Request-call argument indentation; a nested `headers=` expression inside `data=` or another argument is not HTTP-header evidence. Ordinary direct Request keyword arguments such as `data=` and `method=` may appear between the tracked URL argument and the direct `headers=` argument without breaking the path. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. From fc5cdad550c0be4529534ece933b02b3cb24c1a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:11:16 +0900 Subject: [PATCH 064/105] fix(sast): cover fully multiline Bearer mutations --- scanner/rules/ssrf_bearer_multiline.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scanner/rules/ssrf_bearer_multiline.yml b/scanner/rules/ssrf_bearer_multiline.yml index db34c978..e138fadf 100644 --- a/scanner/rules/ssrf_bearer_multiline.yml +++ b/scanner/rules/ssrf_bearer_multiline.yml @@ -15,12 +15,12 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou-multiline-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))[^\n]*\n){0,60}?^(?P[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*(?:\n[ \t]*)?\][ \t]*=[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n)?[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=mutindent)(?:return|raise)\b)(?!^(?P=mutindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))(?!^(?P=mutindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]\)| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b| ^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=reqargindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]*\)[^\n]*\n)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))[^\n]*\n){0,60}?^(?P[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*(?:\n[ \t]*)?\][ \t]*=[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n)?[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=mutindent)(?:return|raise)\b)(?!^(?P=mutindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))(?!^(?P=mutindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]\)| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b| ^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' message: | A Bearer credential added to a validated urllib request is dispatched through a resolver-backed transport after the preflight. This companion rule covers supported - multiline header-mutation formatting while preserving destination, request, - credential, and reachability barriers. + multiline header-mutation formatting, including a line-wrapped Request constructor, + while preserving destination, request, credential, and reachability barriers. [CWE-367 - Time-of-check Time-of-use Race Condition] [CWE-918 - Server-Side Request Forgery] severity: HIGH languages: [python] From a06ddce3bbb4f3ce38a472d31abcb8ba0f7882e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:13:35 +0900 Subject: [PATCH 065/105] test(sast): cover fully multiline Bearer mutation flows --- ...bearer_dns_toctou_multiline_regressions.py | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/test_bearer_dns_toctou_multiline_regressions.py b/tests/test_bearer_dns_toctou_multiline_regressions.py index 431bd4e5..3af42714 100644 --- a/tests/test_bearer_dns_toctou_multiline_regressions.py +++ b/tests/test_bearer_dns_toctou_multiline_regressions.py @@ -249,3 +249,118 @@ def test_multiline_remove_then_restore_remains_detectable(tmp_path): return urllib.request.urlopen(req) """ assert _family_ids(tmp_path, source) == [_MULTILINE_MUTATION] + + +def test_fully_multiline_request_then_add_header_is_detected_once(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request( + endpoint, + data=payload, + method="POST", + ) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_MUTATION] + + +def test_fully_multiline_request_then_add_unredirected_header_is_detected_once(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request( + url=endpoint, + method="POST", + ) + req.add_unredirected_header( + "Authorization", + f"Bearer {api_key}", + ) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_MUTATION] + + +def test_fully_multiline_request_then_direct_assignment_is_detected_once(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request( + endpoint, + data=payload, + ) + req.headers[ + "Authorization" + ] = ( + f"Bearer {api_key}" + ) + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_MUTATION] + + +def test_fully_multiline_mutation_stops_at_request_replacement(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request( + endpoint, + method="POST", + ) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + req = urllib.request.Request("https://fixed.example/api") + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_fully_multiline_mutation_stops_after_authorization_removal(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request( + endpoint, + method="POST", + ) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + req.remove_header("Authorization") + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_fully_multiline_mutation_stops_at_unreachable_dispatch(tmp_path): + source = _prefix() + """\ + req = urllib.request.Request( + endpoint, + method="POST", + ) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + return None + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_fully_multiline_mutation_does_not_cross_opposite_branch(tmp_path): + source = _prefix().replace( + "def deliver(url, api_key):", "def deliver(url, api_key, enabled):" + ) + """\ + req = urllib.request.Request( + endpoint, + method="POST", + ) + if enabled: + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + else: + return urllib.request.urlopen(req) + return None +""" + assert not _family_ids(tmp_path, source) From 39602267bf331b3104b8b7c333f8fec1b8b31d18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:14:48 +0900 Subject: [PATCH 066/105] docs(security): clarify multiline endpoint barrier --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index ff38a659..0df3409b 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -55,7 +55,7 @@ Security issue #892 and merged PR #898 establish the runtime defect and repair: PR #1080 is the integration record for the independent scanner obligation. The detector family keeps four executable rule identities rather than overloading one rule ID or forcing unrelated syntax forms through one expression. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. `python-bearer-preflight-dns-toctou-multiline-constructor` and `python-bearer-preflight-dns-toctou-multiline-header-mutation` cover the corresponding reviewed line-wrapped credential forms without changing the defect identity or broadening the transport boundary. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. -The multiline companion rules inherit the same destination, request-identity, credential-state, and reachability barriers as their direct-layout family members. Before dispatch, unrelated validated-URL or endpoint replacement, tracked-request replacement, unconditional same-path `return`/`raise`, Authorization removal, or a statically non-Bearer Authorization overwrite terminates the corresponding path; self-derived endpoint/request state remains eligible, and a later supported Bearer restoration can re-establish credential provenance. `tests/test_bearer_dns_toctou_multiline_regressions.py` executes these boundaries through production `_scan_file`, including paired vulnerable, sanitized, dead-sink, self-derived, and remove-then-restore cases. +The multiline companion rules inherit the same destination, request-identity, credential-state, and reachability barriers as their direct-layout family members. Before Request construction, replacing the validated URL or URL-derived endpoint with an unrelated destination breaks destination provenance. After Request construction, reassigning the endpoint variable alone does not sanitize the already-bound Request; before dispatch, tracked-request replacement, unconditional same-path `return`/`raise`, Authorization removal, or a statically non-Bearer Authorization overwrite terminates the corresponding live request path. Self-derived endpoint/request state remains eligible before it is bound, and a later supported Bearer restoration can re-establish credential provenance. `tests/test_bearer_dns_toctou_multiline_regressions.py` executes these boundaries through production `_scan_file`, including paired vulnerable, sanitized, dead-sink, self-derived, fully multiline constructor-plus-mutation, and remove-then-restore cases. Request construction is destination- and credential-bound rather than token-text-bound. The tracked endpoint must be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. For the primary Request-credential path, `Authorization: Bearer ...` counts only when it is in the supported direct `headers=` argument at Request-call argument indentation; a nested `headers=` expression inside `data=` or another argument is not HTTP-header evidence. Ordinary direct Request keyword arguments such as `data=` and `method=` may appear between the tracked URL argument and the direct `headers=` argument without breaking the path. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. From 5a8ea1b7e96dee020b2f8e5b9d0de16a907cdd01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:26:36 +0900 Subject: [PATCH 067/105] test(sast): cover request state mutation boundaries --- ...arer_dns_toctou_request_state_mutations.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_request_state_mutations.py diff --git a/tests/test_bearer_dns_toctou_request_state_mutations.py b/tests/test_bearer_dns_toctou_request_state_mutations.py new file mode 100644 index 00000000..00c2d295 --- /dev/null +++ b/tests/test_bearer_dns_toctou_request_state_mutations.py @@ -0,0 +1,110 @@ +"""Regression boundaries for post-construction urllib Request state mutations.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_PRIMARY = "python-bearer-preflight-dns-toctou" +_DYNAMIC = "python-bearer-preflight-dns-toctou-dynamic-bearer-replacement" +_FAMILY = { + _PRIMARY, + "python-bearer-preflight-dns-toctou-header-mutation", + "python-bearer-preflight-dns-toctou-multiline-constructor", + "python-bearer-preflight-dns-toctou-multiline-header-mutation", + _DYNAMIC, +} + + +def _scan_source(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def _direct_bearer_prefix() -> str: + return """\ +def deliver(url, api_key, replacement=None): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) +""" + + +def test_dynamic_authorization_assignment_breaks_unproven_bearer_state(tmp_path): + source = _direct_bearer_prefix() + """\ + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_dynamic_add_header_breaks_unproven_bearer_state(tmp_path): + source = _direct_bearer_prefix() + """\ + req.add_header("Authorization", replacement) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_headers_mapping_replacement_breaks_bearer_state(tmp_path): + source = _direct_bearer_prefix() + """\ + req.headers = {} + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_headers_clear_breaks_bearer_state(tmp_path): + source = _direct_bearer_prefix() + """\ + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_fixed_full_url_replacement_breaks_destination_provenance(tmp_path): + source = _direct_bearer_prefix() + """\ + req.full_url = "https://fixed.example/api/v1/scans" + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_self_derived_full_url_mutation_preserves_destination_provenance(tmp_path): + source = _direct_bearer_prefix() + """\ + req.full_url = endpoint + "?source=retry" + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert len(findings) == 1 + assert findings[0]["rule_id"] == _PRIMARY + + +def test_provable_dynamic_bearer_assignment_remains_detectable(tmp_path): + source = _direct_bearer_prefix() + """\ + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert len(findings) == 1 + assert findings[0]["rule_id"] == _DYNAMIC + + +def test_provable_dynamic_bearer_add_header_remains_detectable(tmp_path): + source = _direct_bearer_prefix() + """\ + replacement = "Bearer " + api_key + req.add_header("Authorization", replacement) + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert len(findings) == 1 + assert findings[0]["rule_id"] == _DYNAMIC From abb0e0b52af9e9ce9fac7075bcbed5ebe501abe8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:27:43 +0900 Subject: [PATCH 068/105] fix(sast): track request destination and credential mutation --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index b987fa70..3aa55c1c 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27])[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b)[^\n]*|(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27])).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27])).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers\.clear[ \t]*\([ \t]*\)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b)[^\n]*|(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers\.clear[ \t]*\([ \t]*\)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+)).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers\.clear[ \t]*\([ \t]*\)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+)).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From ffeed6181134ae0baf523dc0ea16b43a035c26f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:29:06 +0900 Subject: [PATCH 069/105] feat(sast): detect provable dynamic Bearer replacement --- scanner/rules/ssrf_bearer_dynamic.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 scanner/rules/ssrf_bearer_dynamic.yml diff --git a/scanner/rules/ssrf_bearer_dynamic.yml b/scanner/rules/ssrf_bearer_dynamic.yml new file mode 100644 index 00000000..3d0f16a1 --- /dev/null +++ b/scanner/rules/ssrf_bearer_dynamic.yml @@ -0,0 +1,16 @@ +rules: + - id: python-bearer-preflight-dns-toctou-dynamic-bearer-replacement + patterns: + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=argindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=argindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,12}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1600}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,800}?^(?P=bodyindent)(?:(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=replacement)\b|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=replacement)\b)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]|(?P=req)\.headers\.clear[ \t]*\(|(?P=req)\.headers[ \t]*=[ \t]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?![ \t]*(?P=replacement)\b)|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?![ \t]*(?P=replacement)\b)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)))[^\n]*\n).){0,1800}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b' + message: | + A provably Bearer-valued replacement is applied to an urllib Request after a + separate URL-safety preflight, and the live request is dispatched through a + resolver-backed transport. The dispatch can repeat DNS resolution after the + validation decision. Connect through the already-validated address set. + [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] + [CWE-918 - Server-Side Request Forgery] + severity: HIGH + languages: [python] + prefilter: [_is_safe_url, urllib.request.Request] + cwe: [CWE-367, CWE-918] + owasp: [A10:2021] From 2d34e41a94063fe293842e4fa6a3ea925c79d5cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:29:20 +0900 Subject: [PATCH 070/105] docs(security): record request-state provenance boundaries --- CHANGELOG.d/892-bearer-dns-toctou-detector.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.d/892-bearer-dns-toctou-detector.md b/CHANGELOG.d/892-bearer-dns-toctou-detector.md index 67e0e8c5..941db036 100644 --- a/CHANGELOG.d/892-bearer-dns-toctou-detector.md +++ b/CHANGELOG.d/892-bearer-dns-toctou-detector.md @@ -1,3 +1,4 @@ ## Security -- Add HIGH built-in detector `python-bearer-preflight-dns-toctou` for bearer-authenticated Python `urllib` flows that validate a URL before dispatch but allow the network client to make a second DNS decision. The source-backed regression corpus preserves the pre-PR #898 vulnerable flow and the protected DNS-pinned HTTPS repair for security issue #892. +- Add the HIGH built-in Bearer DNS-rebinding TOCTOU detector family for Python `urllib` flows that validate a URL before dispatch but allow the network client to make a second DNS decision. The source-backed regression corpus preserves the pre-PR #898 vulnerable flow and the protected DNS-pinned HTTPS repair for security issue #892. +- Track post-construction request state instead of treating the original constructor as permanently authoritative: opaque/dynamic Authorization replacement, header mapping replacement/clearing, and unrelated `Request.full_url` mutation terminate stale credential or destination provenance. A narrowly scoped companion rule keeps provably Bearer-valued variable replacements detectable, while self-derived destination mutations remain positive. From a4421d76ca31fb18215c75588b1381f4c0289c8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:31:40 +0900 Subject: [PATCH 071/105] test(sast): assert dynamic Bearer rule packaging --- tests/test_bearer_dns_toctou_request_state_mutations.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_bearer_dns_toctou_request_state_mutations.py b/tests/test_bearer_dns_toctou_request_state_mutations.py index 00c2d295..3da56e81 100644 --- a/tests/test_bearer_dns_toctou_request_state_mutations.py +++ b/tests/test_bearer_dns_toctou_request_state_mutations.py @@ -2,7 +2,7 @@ from pathlib import Path -from scanner.cli.appguardrail import _scan_file +from scanner.cli.appguardrail import SCAN_RULES, _scan_file _PRIMARY = "python-bearer-preflight-dns-toctou" _DYNAMIC = "python-bearer-preflight-dns-toctou-dynamic-bearer-replacement" @@ -38,6 +38,11 @@ def deliver(url, api_key, replacement=None): """ +def test_dynamic_bearer_companion_is_packaged_once(): + loaded = [rule["id"] for rule in SCAN_RULES if rule["id"] == _DYNAMIC] + assert loaded == [_DYNAMIC] + + def test_dynamic_authorization_assignment_breaks_unproven_bearer_state(tmp_path): source = _direct_bearer_prefix() + """\ req.headers["Authorization"] = replacement From c7cbe1dc9f0565260fce4f157dfacc8d9e25dbe3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:59:16 +0900 Subject: [PATCH 072/105] fix(sast): compile dynamic bearer replacement detector --- scanner/rules/ssrf_bearer_dynamic.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_dynamic.yml b/scanner/rules/ssrf_bearer_dynamic.yml index 3d0f16a1..9dd192e0 100644 --- a/scanner/rules/ssrf_bearer_dynamic.yml +++ b/scanner/rules/ssrf_bearer_dynamic.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-dynamic-bearer-replacement patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=argindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=argindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,12}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1600}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,800}?^(?P=bodyindent)(?:(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=replacement)\b|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=replacement)\b)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]|(?P=req)\.headers\.clear[ \t]*\(|(?P=req)\.headers[ \t]*=[ \t]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?![ \t]*(?P=replacement)\b)|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?![ \t]*(?P=replacement)\b)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)))[^\n]*\n).){0,1800}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,20}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?\n)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,40}?^(?P=bodyindent)(?:(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=replacement)\b|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=replacement)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?P=replacement)\b)| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?P=replacement)\b)| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)))[^\n]*\n){0,80}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b' message: | A provably Bearer-valued replacement is applied to an urllib Request after a separate URL-safety preflight, and the live request is dispatched through a From 4126fb248a2643947c092701062dcee4d7e93434 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:59:39 +0900 Subject: [PATCH 073/105] test(sast): require compiled dynamic bearer detector --- ...arer_dns_toctou_request_state_mutations.py | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/test_bearer_dns_toctou_request_state_mutations.py b/tests/test_bearer_dns_toctou_request_state_mutations.py index 3da56e81..a7582bbc 100644 --- a/tests/test_bearer_dns_toctou_request_state_mutations.py +++ b/tests/test_bearer_dns_toctou_request_state_mutations.py @@ -1,5 +1,6 @@ """Regression boundaries for post-construction urllib Request state mutations.""" +import re from pathlib import Path from scanner.cli.appguardrail import SCAN_RULES, _scan_file @@ -38,9 +39,10 @@ def deliver(url, api_key, replacement=None): """ -def test_dynamic_bearer_companion_is_packaged_once(): - loaded = [rule["id"] for rule in SCAN_RULES if rule["id"] == _DYNAMIC] - assert loaded == [_DYNAMIC] +def test_dynamic_bearer_companion_is_packaged_once_and_compiled(): + loaded = [rule for rule in SCAN_RULES if rule["id"] == _DYNAMIC] + assert len(loaded) == 1 + assert isinstance(loaded[0]["pattern"], re.Pattern) def test_dynamic_authorization_assignment_breaks_unproven_bearer_state(tmp_path): @@ -113,3 +115,33 @@ def test_provable_dynamic_bearer_add_header_remains_detectable(tmp_path): findings = _scan_source(tmp_path, source) assert len(findings) == 1 assert findings[0]["rule_id"] == _DYNAMIC + + +def test_dynamic_bearer_then_header_clear_is_sanitized(tmp_path): + source = _direct_bearer_prefix() + """\ + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_dynamic_bearer_then_opaque_replacement_is_sanitized(tmp_path): + source = _direct_bearer_prefix() + """\ + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + req.headers["Authorization"] = fallback + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_dynamic_bearer_then_fixed_destination_is_sanitized(tmp_path): + source = _direct_bearer_prefix() + """\ + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + req.full_url = "https://fixed.example/api/v1/scans" + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) From 8a81dd05adc5f9a38cfc4bb4fcbd10a06394ba7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:01:42 +0900 Subject: [PATCH 074/105] fix(sast): carry request-state barriers into multiline bearer rules --- scanner/rules/ssrf_bearer_multiline.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scanner/rules/ssrf_bearer_multiline.yml b/scanner/rules/ssrf_bearer_multiline.yml index e138fadf..7e4d2409 100644 --- a/scanner/rules/ssrf_bearer_multiline.yml +++ b/scanner/rules/ssrf_bearer_multiline.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-multiline-constructor patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]{0,500}?["\x27]Authorization["\x27][ \t]*:[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=argindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=argindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,800}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n|(?:\#[^\n]*)?\n)[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]\)| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b| ^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n#}]{0,500}?["\x27]Authorization["\x27][ \t]*:[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=argindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=argindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,800}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n|(?:\#[^\n]*)?\n)[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' message: | A Bearer-authenticated urllib request is dispatched after a separate URL safety preflight, so connection-time DNS resolution can diverge from validation. This @@ -15,7 +15,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou-multiline-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=reqargindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]*\)[^\n]*\n)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))[^\n]*\n){0,60}?^(?P[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*(?:\n[ \t]*)?\][ \t]*=[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n)?[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=mutindent)(?:return|raise)\b)(?!^(?P=mutindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n\#]*\b(?P=req)\b))(?!^(?P=mutindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]\)| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*["\x27](?!Bearer(?:[ \t]|["\x27]))[^"\x27\n]*["\x27]))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b| ^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=reqargindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]*\)[^\n]*\n)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,60}?^(?P[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*(?:\n[ \t]*)?\][ \t]*=[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n)?[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=mutindent)(?:return|raise)\b)(?!^(?P=mutindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=mutindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' message: | A Bearer credential added to a validated urllib request is dispatched through a resolver-backed transport after the preflight. This companion rule covers supported From 427d4f2e070865f875d05b34c280f6f249cbc8c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:02:01 +0900 Subject: [PATCH 075/105] test(sast): cover multiline request-state barriers --- ...rer_dns_toctou_multiline_state_barriers.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_multiline_state_barriers.py diff --git a/tests/test_bearer_dns_toctou_multiline_state_barriers.py b/tests/test_bearer_dns_toctou_multiline_state_barriers.py new file mode 100644 index 00000000..af4805cd --- /dev/null +++ b/tests/test_bearer_dns_toctou_multiline_state_barriers.py @@ -0,0 +1,126 @@ +"""State-barrier regressions for multiline Bearer DNS-TOCTOU rules.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_MULTILINE_CONSTRUCTOR = "python-bearer-preflight-dns-toctou-multiline-constructor" +_MULTILINE_MUTATION = "python-bearer-preflight-dns-toctou-multiline-header-mutation" +_FAMILY = {_MULTILINE_CONSTRUCTOR, _MULTILINE_MUTATION} + + +def _family_ids(tmp_path: Path, source: str) -> list[str]: + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding["rule_id"] + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def _prefix() -> str: + return """\ +def deliver(url, api_key, replacement=None): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" +""" + + +def _wrapped_constructor() -> str: + return _prefix() + """\ + req = urllib.request.Request(endpoint, headers={"Authorization": ( + f"Bearer {api_key}" + )}) +""" + + +def _wrapped_mutation() -> str: + return _prefix() + """\ + req = urllib.request.Request(endpoint) + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) +""" + + +def test_multiline_constructor_headers_clear_breaks_credential_state(tmp_path): + source = _wrapped_constructor() + """\ + req.headers.clear() + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_constructor_headers_mapping_replacement_breaks_credential_state(tmp_path): + source = _wrapped_constructor() + """\ + req.headers = {} + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_constructor_opaque_authorization_replacement_breaks_credential_state(tmp_path): + source = _wrapped_constructor() + """\ + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_constructor_fixed_full_url_breaks_destination_state(tmp_path): + source = _wrapped_constructor() + """\ + req.full_url = "https://fixed.example/api/v1/scans" + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_constructor_self_derived_full_url_preserves_destination_state(tmp_path): + source = _wrapped_constructor() + """\ + req.full_url = endpoint + "?retry=1" + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_CONSTRUCTOR] + + +def test_multiline_mutation_headers_clear_breaks_credential_state(tmp_path): + source = _wrapped_mutation() + """\ + req.headers.clear() + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_mutation_headers_mapping_replacement_breaks_credential_state(tmp_path): + source = _wrapped_mutation() + """\ + req.headers = {} + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_mutation_opaque_authorization_replacement_breaks_credential_state(tmp_path): + source = _wrapped_mutation() + """\ + req.add_header("Authorization", replacement) + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_mutation_fixed_full_url_breaks_destination_state(tmp_path): + source = _wrapped_mutation() + """\ + req.full_url = "https://fixed.example/api/v1/scans" + return urllib.request.urlopen(req) +""" + assert not _family_ids(tmp_path, source) + + +def test_multiline_mutation_self_derived_full_url_preserves_destination_state(tmp_path): + source = _wrapped_mutation() + """\ + req.full_url = endpoint + "?retry=1" + return urllib.request.urlopen(req) +""" + assert _family_ids(tmp_path, source) == [_MULTILINE_MUTATION] From e2286256abb04945d15a00efd23661d70a1269dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:11:21 +0900 Subject: [PATCH 076/105] test(sast): cover dynamic Bearer reviewed opener --- ...arer_dns_toctou_request_state_mutations.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_bearer_dns_toctou_request_state_mutations.py b/tests/test_bearer_dns_toctou_request_state_mutations.py index a7582bbc..621564e6 100644 --- a/tests/test_bearer_dns_toctou_request_state_mutations.py +++ b/tests/test_bearer_dns_toctou_request_state_mutations.py @@ -145,3 +145,37 @@ def test_dynamic_bearer_then_fixed_destination_is_sanitized(tmp_path): return urllib.request.urlopen(req, timeout=5) """ assert not _scan_source(tmp_path, source) + + +def test_dynamic_bearer_reviewed_opener_remains_detectable(tmp_path): + source = _direct_bearer_prefix() + """\ + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + opener = urllib.request.build_opener(SafeRedirectHandler()) + return opener.open(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert len(findings) == 1 + assert findings[0]["rule_id"] == _DYNAMIC + + +def test_dynamic_bearer_reviewed_opener_header_clear_is_sanitized(tmp_path): + source = _direct_bearer_prefix() + """\ + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + opener = urllib.request.build_opener(SafeRedirectHandler()) + req.headers.clear() + return opener.open(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_dynamic_bearer_reviewed_opener_request_replacement_is_sanitized(tmp_path): + source = _direct_bearer_prefix() + """\ + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + opener = urllib.request.build_opener(SafeRedirectHandler()) + req = urllib.request.Request("https://fixed.example/api/v1/scans") + return opener.open(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) From 331f04c1c05848b7d8e1cda54f7621e1f994fb0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:11:57 +0900 Subject: [PATCH 077/105] fix(sast): detect dynamic Bearer reviewed opener --- scanner/rules/ssrf_bearer_dynamic.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_dynamic.yml b/scanner/rules/ssrf_bearer_dynamic.yml index 9dd192e0..0df66bda 100644 --- a/scanner/rules/ssrf_bearer_dynamic.yml +++ b/scanner/rules/ssrf_bearer_dynamic.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-dynamic-bearer-replacement patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,20}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?\n)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,40}?^(?P=bodyindent)(?:(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=replacement)\b|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=replacement)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?P=replacement)\b)| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?P=replacement)\b)| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)))[^\n]*\n){0,80}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,20}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?\n)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,40}?^(?P=bodyindent)(?:(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=replacement)\b|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=replacement)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?P=replacement)\b)| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?P=replacement)\b)| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?(?:^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P(?P=bodyindent)[ \t]*)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=openerindent)(?:return|raise)\b)(?!^(?P=openerindent)(?P=opener)[ \t]*=)(?!^(?P=openerindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=openerindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?P=replacement)\b)| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?P=replacement)\b)| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,40}?^(?P=openerindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' message: | A provably Bearer-valued replacement is applied to an urllib Request after a separate URL-safety preflight, and the live request is dispatched through a From e41dac021869f73b3c3576a86586206155dda9dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:54:55 +0900 Subject: [PATCH 078/105] fix(sast): restore compiled dynamic bearer detector --- scanner/rules/ssrf_bearer_dynamic_compiled.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 scanner/rules/ssrf_bearer_dynamic_compiled.yml diff --git a/scanner/rules/ssrf_bearer_dynamic_compiled.yml b/scanner/rules/ssrf_bearer_dynamic_compiled.yml new file mode 100644 index 00000000..b36a0dc6 --- /dev/null +++ b/scanner/rules/ssrf_bearer_dynamic_compiled.yml @@ -0,0 +1,16 @@ +rules: + - id: python-bearer-preflight-dns-toctou-dynamic-bearer-replacement + patterns: + - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,6000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[^\n]*\b(?P=ep)\b[^\n]*\)|[ \t]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){1,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,1200}?^(?P=bi)(?:(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=v)\b|(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=v)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,80}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' + message: | + A locally provable Bearer replacement is applied to a live urllib Request after + a separate URL-safety preflight, and that request is dispatched through a + resolver-backed urllib transport. Connect through the already-validated + address set instead of resolving the hostname again. + [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] + [CWE-918 - Server-Side Request Forgery] + severity: HIGH + languages: [python] + prefilter: [_is_safe_url, urllib.request.Request] + cwe: [CWE-367, CWE-918] + owasp: [A10:2021] From 311062cf212b21dbb95663a5d65005f247668c6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:55:10 +0900 Subject: [PATCH 079/105] fix(sast): track unredirected bearer credential store --- .../ssrf_bearer_unredirected_persistence.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 scanner/rules/ssrf_bearer_unredirected_persistence.yml diff --git a/scanner/rules/ssrf_bearer_unredirected_persistence.yml b/scanner/rules/ssrf_bearer_unredirected_persistence.yml new file mode 100644 index 00000000..ae160f41 --- /dev/null +++ b/scanner/rules/ssrf_bearer_unredirected_persistence.yml @@ -0,0 +1,18 @@ +rules: + - id: python-bearer-preflight-dns-toctou-unredirected-header-persistence + patterns: + - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,5000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[^\n]*\b(?P=ep)\b[^\n]*\)|[ \t]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){1,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P(?P=bi)[ \t]*)(?P=r)\.add_unredirected_header\([ \t]*(?:\n[ \t]+)?["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\n[ \t]+)?(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\)?[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=).){0,1200}?^(?P=mi)(?:(?P=r)\.headers\.clear\(\)|(?P=r)\.headers[ \t]*=[^\n]+|(?P=r)\.headers\.pop\([ \t]*["\x27]Authorization["\x27][^\n]*\)|(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[^\n]+)[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=).){0,1200}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)[ \t]*=)[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' + message: | + Bearer authorization stored by urllib Request.add_unredirected_header remains + active even when the ordinary headers mapping is cleared, replaced, popped, + or overwritten. A later resolver-backed dispatch after a separate URL safety + preflight still carries the unredirected credential and can repeat DNS lookup. + Use remove_header to remove both stores, or connect through the validated + address set. + [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] + [CWE-918 - Server-Side Request Forgery] + severity: HIGH + languages: [python] + prefilter: [_is_safe_url, urllib.request.Request, add_unredirected_header] + cwe: [CWE-367, CWE-918] + owasp: [A10:2021] From f18367ab3d5851fac3d48f74b4b8c593cdd8b6fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:55:25 +0900 Subject: [PATCH 080/105] test(sast): cover unredirected bearer persistence --- ...rer_dns_toctou_unredirected_persistence.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_unredirected_persistence.py diff --git a/tests/test_bearer_dns_toctou_unredirected_persistence.py b/tests/test_bearer_dns_toctou_unredirected_persistence.py new file mode 100644 index 00000000..274b87da --- /dev/null +++ b/tests/test_bearer_dns_toctou_unredirected_persistence.py @@ -0,0 +1,99 @@ +"""Regression coverage for urllib's separate unredirected header store.""" + +import re +from pathlib import Path + +from scanner.cli.appguardrail import SCAN_RULES, _scan_file + +_RULE = "python-bearer-preflight-dns-toctou-unredirected-header-persistence" +_DYNAMIC = "python-bearer-preflight-dns-toctou-dynamic-bearer-replacement" + + +def _scan_rule(tmp_path: Path, source: str, rule_id: str = _RULE): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] == rule_id + ] + + +def _prefix() -> str: + return """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) +""" + + +def test_dynamic_bearer_rule_has_exactly_one_compiled_runtime_entry(): + loaded = [rule for rule in SCAN_RULES if rule["id"] == _DYNAMIC] + assert len(loaded) == 1 + assert isinstance(loaded[0]["pattern"], re.Pattern) + + +def test_unredirected_persistence_rule_is_packaged_and_compiled(): + loaded = [rule for rule in SCAN_RULES if rule["id"] == _RULE] + assert len(loaded) == 1 + assert isinstance(loaded[0]["pattern"], re.Pattern) + + +def test_headers_clear_does_not_remove_unredirected_bearer(tmp_path): + source = _prefix() + """\ + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_rule(tmp_path, source)) == 1 + + +def test_headers_mapping_replacement_does_not_remove_unredirected_bearer(tmp_path): + source = _prefix() + """\ + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers = {} + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_rule(tmp_path, source)) == 1 + + +def test_headers_pop_does_not_remove_unredirected_bearer(tmp_path): + source = _prefix() + """\ + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.pop("Authorization", None) + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_rule(tmp_path, source)) == 1 + + +def test_regular_authorization_overwrite_does_not_remove_unredirected_bearer(tmp_path): + source = _prefix() + """\ + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers["Authorization"] = "Basic dXNlcjpwYXNz" + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_rule(tmp_path, source)) == 1 + + +def test_multiline_unredirected_bearer_survives_headers_clear(tmp_path): + source = _prefix() + """\ + req.add_unredirected_header( + "Authorization", + f"Bearer {api_key}", + ) + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + assert len(_scan_rule(tmp_path, source)) == 1 + + +def test_remove_header_clears_both_header_stores(tmp_path): + source = _prefix() + """\ + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + req.remove_header("Authorization") + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_rule(tmp_path, source) From f3ea510cc05dff33e9de3888cd1ee51eefaf3cfa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:57:23 +0900 Subject: [PATCH 081/105] fix(sast): compile dynamic bearer detector in canonical rule --- scanner/rules/ssrf_bearer_dynamic.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scanner/rules/ssrf_bearer_dynamic.yml b/scanner/rules/ssrf_bearer_dynamic.yml index 0df66bda..b36a0dc6 100644 --- a/scanner/rules/ssrf_bearer_dynamic.yml +++ b/scanner/rules/ssrf_bearer_dynamic.yml @@ -1,12 +1,12 @@ rules: - id: python-bearer-preflight-dns-toctou-dynamic-bearer-replacement patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b).){0,2500}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,20}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?\n)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,40}?^(?P=bodyindent)(?:(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=replacement)\b|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=replacement)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?P=replacement)\b)| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?P=replacement)\b)| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,80}?(?:^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P(?P=bodyindent)[ \t]*)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=openerindent)(?:return|raise)\b)(?!^(?P=openerindent)(?P=opener)[ \t]*=)(?!^(?P=openerindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=openerindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?P=replacement)\b)| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?P=replacement)\b)| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,40}?^(?P=openerindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,6000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[^\n]*\b(?P=ep)\b[^\n]*\)|[ \t]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){1,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,1200}?^(?P=bi)(?:(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=v)\b|(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=v)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,80}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' message: | - A provably Bearer-valued replacement is applied to an urllib Request after a - separate URL-safety preflight, and the live request is dispatched through a - resolver-backed transport. The dispatch can repeat DNS resolution after the - validation decision. Connect through the already-validated address set. + A locally provable Bearer replacement is applied to a live urllib Request after + a separate URL-safety preflight, and that request is dispatched through a + resolver-backed urllib transport. Connect through the already-validated + address set instead of resolving the hostname again. [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] [CWE-918 - Server-Side Request Forgery] severity: HIGH From a84d5bb3235ca27b09d53c540c174fbaed878290 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:57:33 +0900 Subject: [PATCH 082/105] chore(sast): keep one canonical dynamic bearer rule --- scanner/rules/ssrf_bearer_dynamic_compiled.yml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 scanner/rules/ssrf_bearer_dynamic_compiled.yml diff --git a/scanner/rules/ssrf_bearer_dynamic_compiled.yml b/scanner/rules/ssrf_bearer_dynamic_compiled.yml deleted file mode 100644 index b36a0dc6..00000000 --- a/scanner/rules/ssrf_bearer_dynamic_compiled.yml +++ /dev/null @@ -1,16 +0,0 @@ -rules: - - id: python-bearer-preflight-dns-toctou-dynamic-bearer-replacement - patterns: - - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,6000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[^\n]*\b(?P=ep)\b[^\n]*\)|[ \t]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){1,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,1200}?^(?P=bi)(?:(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=v)\b|(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=v)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,80}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' - message: | - A locally provable Bearer replacement is applied to a live urllib Request after - a separate URL-safety preflight, and that request is dispatched through a - resolver-backed urllib transport. Connect through the already-validated - address set instead of resolving the hostname again. - [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] - [CWE-918 - Server-Side Request Forgery] - severity: HIGH - languages: [python] - prefilter: [_is_safe_url, urllib.request.Request] - cwe: [CWE-367, CWE-918] - owasp: [A10:2021] From b952c82ae82d1ada54eed4ce28358b8adf8ac28c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:58:41 +0900 Subject: [PATCH 083/105] docs(security): trace urllib credential-store provenance --- docs/TRACEABILITY.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 0df3409b..1daecaba 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -20,7 +20,7 @@ | authenticated workflow-result detector evidence | issue-detection audit workflow evidence | PR #911 active-PR | | automatic scanner detection of unsafe stored-webhook SSRF pattern | built-in `python-stored-ssrf-webhook-url` rule | implemented-main through PR #910 for tested Python `set_webhook` direct and one-hop persistence flows; bounded scope | | bearer-authenticated DNS rebinding prevention | DNS-pinned HTTPS control-plane transport | implemented-main through PR #898 for the reviewed scan-delivery boundary | -| automatic scanner detection of preflight DNS-validation TOCTOU before bearer urllib dispatch | built-in `python-bearer-preflight-dns-toctou`, `python-bearer-preflight-dns-toctou-header-mutation`, `python-bearer-preflight-dns-toctou-multiline-constructor`, and `python-bearer-preflight-dns-toctou-multiline-header-mutation` detector family | integration evidence: PR #1080; source-backed vulnerable/fixed fixtures and production `_scan_file` regressions must remain with the detector family | +| automatic scanner detection of preflight DNS-validation TOCTOU before bearer urllib dispatch | built-in `python-bearer-preflight-dns-toctou`, `python-bearer-preflight-dns-toctou-header-mutation`, `python-bearer-preflight-dns-toctou-multiline-constructor`, `python-bearer-preflight-dns-toctou-multiline-header-mutation`, `python-bearer-preflight-dns-toctou-dynamic-bearer-replacement`, and `python-bearer-preflight-dns-toctou-unredirected-header-persistence` detector family | integration evidence: PR #1080; source-backed vulnerable/fixed fixtures and production `_scan_file` regressions must remain with the detector family | | structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution | ## Promotion rules @@ -53,15 +53,15 @@ Current protected-branch evidence keeps those controls distinct: PR #924 supplie Security issue #892 and merged PR #898 establish the runtime defect and repair: a bearer-authenticated control-plane push first validated a URL, then `urllib` made a second DNS decision during connection; the protected repair uses DNS-pinned HTTPS so the actual connection uses the validated public address set while retaining the hostname for TLS identity verification. -PR #1080 is the integration record for the independent scanner obligation. The detector family keeps four executable rule identities rather than overloading one rule ID or forcing unrelated syntax forms through one expression. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. `python-bearer-preflight-dns-toctou-multiline-constructor` and `python-bearer-preflight-dns-toctou-multiline-header-mutation` cover the corresponding reviewed line-wrapped credential forms without changing the defect identity or broadening the transport boundary. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. +PR #1080 is the integration record for the independent scanner obligation. The detector family keeps six executable rule identities rather than overloading one rule ID or forcing materially different syntax/state forms through one expression. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. `python-bearer-preflight-dns-toctou-multiline-constructor` and `python-bearer-preflight-dns-toctou-multiline-header-mutation` cover the corresponding reviewed line-wrapped credential forms without changing the defect identity or broadening the transport boundary. `python-bearer-preflight-dns-toctou-dynamic-bearer-replacement` preserves detection when a local replacement variable is provably Bearer-valued before being applied to the tracked request. `python-bearer-preflight-dns-toctou-unredirected-header-persistence` models urllib's separate `unredirected_hdrs` credential store so ordinary `headers`-mapping mutations cannot falsely sanitize an Authorization value that remains active there. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. -The multiline companion rules inherit the same destination, request-identity, credential-state, and reachability barriers as their direct-layout family members. Before Request construction, replacing the validated URL or URL-derived endpoint with an unrelated destination breaks destination provenance. After Request construction, reassigning the endpoint variable alone does not sanitize the already-bound Request; before dispatch, tracked-request replacement, unconditional same-path `return`/`raise`, Authorization removal, or a statically non-Bearer Authorization overwrite terminates the corresponding live request path. Self-derived endpoint/request state remains eligible before it is bound, and a later supported Bearer restoration can re-establish credential provenance. `tests/test_bearer_dns_toctou_multiline_regressions.py` executes these boundaries through production `_scan_file`, including paired vulnerable, sanitized, dead-sink, self-derived, fully multiline constructor-plus-mutation, and remove-then-restore cases. +The multiline companion rules inherit the same destination, request-identity, credential-state, and reachability barriers as their direct-layout family members. Before Request construction, replacing the validated URL or URL-derived endpoint with an unrelated destination breaks destination provenance. After Request construction, reassigning the endpoint variable alone does not sanitize the already-bound Request; before dispatch, tracked-request replacement, unconditional same-path `return`/`raise`, Authorization removal, or a statically non-Bearer Authorization overwrite terminates the corresponding live request path when that operation actually targets the credential store that carries Authorization. Self-derived endpoint/request state remains eligible before it is bound, and a later supported Bearer restoration can re-establish credential provenance. `tests/test_bearer_dns_toctou_multiline_regressions.py` executes these boundaries through production `_scan_file`, including paired vulnerable, sanitized, dead-sink, self-derived, fully multiline constructor-plus-mutation, and remove-then-restore cases. Request construction is destination- and credential-bound rather than token-text-bound. The tracked endpoint must be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. For the primary Request-credential path, `Authorization: Bearer ...` counts only when it is in the supported direct `headers=` argument at Request-call argument indentation; a nested `headers=` expression inside `data=` or another argument is not HTTP-header evidence. Ordinary direct Request keyword arguments such as `data=` and `method=` may appear between the tracked URL argument and the direct `headers=` argument without breaking the path. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. -Credential-removal and request-replacement barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")`, `headers.pop("Authorization", ...)`, or `del headers["Authorization"]` before dispatch breaks primary-rule bearer provenance unless a supported later Bearer mutation re-establishes credential flow; removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. Likewise, a same-branch replacement of the tracked request breaks the original path whenever the replacement destination is not the tracked endpoint, regardless of whether the replacement itself carries Bearer credentials. A rebuild that still targets the tracked endpoint remains detectable when credential provenance also remains, while an unauthenticated same-endpoint rebuild is a credential break. An opposite-branch replacement cannot sanitize a sibling branch that still dispatches the original Bearer request. The production review-boundary regressions preserve these paired cases together with first-positional and keyword-URL positives so later regex changes cannot recover precision by creating silent false negatives. +Credential-removal and request-replacement barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")` before dispatch is a universal credential barrier for urllib Request header storage unless a supported later Bearer mutation re-establishes credential flow. Mutations limited to the ordinary `req.headers` mapping, including `headers.clear()`, `headers = {}`, `headers.pop("Authorization", ...)`, or regular-map Authorization overwrite, terminate provenance only when the tracked credential resides in that mapping; they do not remove a Bearer credential previously installed through `add_unredirected_header`, which lives in `unredirected_hdrs`. Removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. Likewise, a same-branch replacement of the tracked request breaks the original path whenever the replacement destination is not the tracked endpoint, regardless of whether the replacement itself carries Bearer credentials. A rebuild that still targets the tracked endpoint remains detectable when credential provenance also remains, while an unauthenticated same-endpoint rebuild is a credential break. An opposite-branch replacement cannot sanitize a sibling branch that still dispatches the original Bearer request. The production review-boundary regressions preserve these paired cases together with first-positional and keyword-URL positives so later regex changes cannot recover precision by creating silent false negatives. -Post-construction Bearer mutation is an independent credential source for the header-mutation subrule and does not require a preceding removal. `add_header("Authorization", "Bearer ...")`, `add_unredirected_header(...)`, or `headers["Authorization"] = "Bearer ..."` can therefore establish the credential-bearing DNS-re-resolution path on an initially unauthenticated tracked Request; the same forms also restore the path after an explicit removal. The mutation path still requires the validated destination and request identity to remain live through the reviewed re-resolving sink, and it breaks provenance when the validated URL/endpoint is replaced before Request construction, the tracked request is replaced before dispatch, a non-Bearer authorization is used instead, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so false-positive barriers do not become silent false-negative sanitizers. Bearer-looking text nested in request data is not used as HTTP-header evidence. +Post-construction Bearer mutation is an independent credential source for the header-mutation subrule and does not require a preceding removal. `add_header("Authorization", "Bearer ...")`, `add_unredirected_header(...)`, or `headers["Authorization"] = "Bearer ..."` can therefore establish the credential-bearing DNS-re-resolution path on an initially unauthenticated tracked Request; the same forms also restore the path after an explicit removal. Because `add_unredirected_header` stores its value separately from `headers`, the unredirected-header persistence companion preserves a finding after ordinary-header clear/replacement/pop/overwrite and treats `remove_header("Authorization")` as the supported operation that clears both stores. `tests/test_bearer_dns_toctou_unredirected_persistence.py` fixes that store-aware contract through production `_scan_file`, including direct and multiline credential installation plus a `remove_header` negative. The mutation path still requires the validated destination and request identity to remain live through the reviewed re-resolving sink, and it breaks provenance when the validated URL/endpoint is replaced before Request construction, the tracked request is replaced before dispatch, a non-Bearer authorization actually replaces the active credential, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so false-positive barriers do not become silent false-negative sanitizers. Bearer-looking text nested in request data is not used as HTTP-header evidence. Simple self-derived assignments preserve the tracked path when an executable tracked identifier appears before any string literal or comment on the right-hand side, including `endpoint = endpoint + ...`, URL-derived endpoint updates, and `req = req`. An assignment where the tracked name occurs only as quoted data, such as `endpoint = choose("endpoint")` or `req = choose("req")`, is a provenance break and must not create a HIGH finding. More general wrapper/helper-mediated value flow that places a literal before the tracked identifier is outside this bounded regex detector and requires separate executable evidence rather than speculative flow inference. Paired production `_scan_file` regressions keep both the self-derived positives and quoted-name replacement negatives stable. From e2c8fccf9811707f825eea8da5340ab2e3e28d1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:00:06 +0900 Subject: [PATCH 084/105] docs(security): record unredirected bearer persistence --- CHANGELOG.d/892-bearer-dns-toctou-detector.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.d/892-bearer-dns-toctou-detector.md b/CHANGELOG.d/892-bearer-dns-toctou-detector.md index 941db036..90489bd6 100644 --- a/CHANGELOG.d/892-bearer-dns-toctou-detector.md +++ b/CHANGELOG.d/892-bearer-dns-toctou-detector.md @@ -2,3 +2,4 @@ - Add the HIGH built-in Bearer DNS-rebinding TOCTOU detector family for Python `urllib` flows that validate a URL before dispatch but allow the network client to make a second DNS decision. The source-backed regression corpus preserves the pre-PR #898 vulnerable flow and the protected DNS-pinned HTTPS repair for security issue #892. - Track post-construction request state instead of treating the original constructor as permanently authoritative: opaque/dynamic Authorization replacement, header mapping replacement/clearing, and unrelated `Request.full_url` mutation terminate stale credential or destination provenance. A narrowly scoped companion rule keeps provably Bearer-valued variable replacements detectable, while self-derived destination mutations remain positive. +- Model urllib credential storage precisely for `add_unredirected_header`: Authorization stored in `unredirected_hdrs` survives ordinary `req.headers` clear/replacement/pop/overwrite operations, so the detector retains the credential-bearing TOCTOU path until `remove_header("Authorization")` or request replacement actually removes that store. Production scanner regressions preserve direct and multiline positive cases plus the universal-removal negative. From 2fc31eabe0894db68e98b1ffde6628c062e4ebff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:00:58 +0900 Subject: [PATCH 085/105] test(sast): enforce unredirected family ownership --- ...rer_dns_toctou_unredirected_persistence.py | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/tests/test_bearer_dns_toctou_unredirected_persistence.py b/tests/test_bearer_dns_toctou_unredirected_persistence.py index 274b87da..d78eecae 100644 --- a/tests/test_bearer_dns_toctou_unredirected_persistence.py +++ b/tests/test_bearer_dns_toctou_unredirected_persistence.py @@ -7,16 +7,28 @@ _RULE = "python-bearer-preflight-dns-toctou-unredirected-header-persistence" _DYNAMIC = "python-bearer-preflight-dns-toctou-dynamic-bearer-replacement" +_FAMILY = { + "python-bearer-preflight-dns-toctou", + "python-bearer-preflight-dns-toctou-header-mutation", + "python-bearer-preflight-dns-toctou-multiline-constructor", + "python-bearer-preflight-dns-toctou-multiline-header-mutation", + _DYNAMIC, + _RULE, +} -def _scan_rule(tmp_path: Path, source: str, rule_id: str = _RULE): +def _scan(tmp_path: Path, source: str): source_file = tmp_path / "push.py" source_file.write_text(source, encoding="utf-8") - return [ - finding - for finding in _scan_file(source_file, tmp_path) - if finding["rule_id"] == rule_id - ] + return _scan_file(source_file, tmp_path) + + +def _scan_rule(tmp_path: Path, source: str, rule_id: str = _RULE): + return [finding for finding in _scan(tmp_path, source) if finding["rule_id"] == rule_id] + + +def _family_findings(tmp_path: Path, source: str): + return [finding for finding in _scan(tmp_path, source) if finding["rule_id"] in _FAMILY] def _prefix() -> str: @@ -47,7 +59,8 @@ def test_headers_clear_does_not_remove_unredirected_bearer(tmp_path): req.headers.clear() return urllib.request.urlopen(req, timeout=5) """ - assert len(_scan_rule(tmp_path, source)) == 1 + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_RULE] def test_headers_mapping_replacement_does_not_remove_unredirected_bearer(tmp_path): @@ -56,7 +69,8 @@ def test_headers_mapping_replacement_does_not_remove_unredirected_bearer(tmp_pat req.headers = {} return urllib.request.urlopen(req, timeout=5) """ - assert len(_scan_rule(tmp_path, source)) == 1 + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_RULE] def test_headers_pop_does_not_remove_unredirected_bearer(tmp_path): @@ -65,7 +79,8 @@ def test_headers_pop_does_not_remove_unredirected_bearer(tmp_path): req.headers.pop("Authorization", None) return urllib.request.urlopen(req, timeout=5) """ - assert len(_scan_rule(tmp_path, source)) == 1 + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_RULE] def test_regular_authorization_overwrite_does_not_remove_unredirected_bearer(tmp_path): @@ -74,7 +89,8 @@ def test_regular_authorization_overwrite_does_not_remove_unredirected_bearer(tmp req.headers["Authorization"] = "Basic dXNlcjpwYXNz" return urllib.request.urlopen(req, timeout=5) """ - assert len(_scan_rule(tmp_path, source)) == 1 + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_RULE] def test_multiline_unredirected_bearer_survives_headers_clear(tmp_path): @@ -86,7 +102,8 @@ def test_multiline_unredirected_bearer_survives_headers_clear(tmp_path): req.headers.clear() return urllib.request.urlopen(req, timeout=5) """ - assert len(_scan_rule(tmp_path, source)) == 1 + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_RULE] def test_remove_header_clears_both_header_stores(tmp_path): @@ -96,4 +113,4 @@ def test_remove_header_clears_both_header_stores(tmp_path): req.remove_header("Authorization") return urllib.request.urlopen(req, timeout=5) """ - assert not _scan_rule(tmp_path, source) + assert not _family_findings(tmp_path, source) From f12c99c6b09736c895f0be73d2b691bea5a7eba0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:12:33 +0900 Subject: [PATCH 086/105] fix(sast): bind dynamic bearer Request destination --- scanner/rules/ssrf_bearer_dynamic.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_dynamic.yml b/scanner/rules/ssrf_bearer_dynamic.yml index b36a0dc6..d6b1b551 100644 --- a/scanner/rules/ssrf_bearer_dynamic.yml +++ b/scanner/rules/ssrf_bearer_dynamic.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-dynamic-bearer-replacement patterns: - - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,6000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[^\n]*\b(?P=ep)\b[^\n]*\)|[ \t]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){1,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,1200}?^(?P=bi)(?:(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=v)\b|(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=v)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,80}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' + - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,6000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\)|[ \t]*\n(?:^[ \t]*\n|^(?P=bi)[ \t]+\#[^\n]*\n){0,8}^(?P=bi)[ \t]+(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,1200}?^(?P=bi)(?:(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=v)\b|(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=v)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,80}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' message: | A locally provable Bearer replacement is applied to a live urllib Request after a separate URL-safety preflight, and that request is dispatched through a From 2fbae8d9d9be638c6f31092deedaa278fe20b2ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:12:52 +0900 Subject: [PATCH 087/105] fix(sast): model urllib header-store precedence --- .../rules/ssrf_bearer_unredirected_persistence.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scanner/rules/ssrf_bearer_unredirected_persistence.yml b/scanner/rules/ssrf_bearer_unredirected_persistence.yml index ae160f41..f449642e 100644 --- a/scanner/rules/ssrf_bearer_unredirected_persistence.yml +++ b/scanner/rules/ssrf_bearer_unredirected_persistence.yml @@ -1,14 +1,14 @@ rules: - id: python-bearer-preflight-dns-toctou-unredirected-header-persistence patterns: - - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,5000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[^\n]*\b(?P=ep)\b[^\n]*\)|[ \t]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){1,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P(?P=bi)[ \t]*)(?P=r)\.add_unredirected_header\([ \t]*(?:\n[ \t]+)?["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\n[ \t]+)?(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\)?[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=).){0,1200}?^(?P=mi)(?:(?P=r)\.headers\.clear\(\)|(?P=r)\.headers[ \t]*=[^\n]+|(?P=r)\.headers\.pop\([ \t]*["\x27]Authorization["\x27][^\n]*\)|(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[^\n]+)[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=).){0,1200}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)[ \t]*=)[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' + - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,5000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\)|[ \t]*\n(?:^[ \t]*\n|^(?P=bi)[ \t]+\#[^\n]*\n){0,8}^(?P=bi)[ \t]+(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P(?P=bi)[ \t]*)(?P=r)\.add_unredirected_header\([ \t]*(?:\n[ \t]+)?["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\n[ \t]+)?(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\)?[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=).){0,1200}?^(?P=mi)(?:(?P=r)\.headers\.clear\(\)|(?P=r)\.headers[ \t]*=[ \t]*\{[ \t]*\}[ \t]*(?:\#[^\n]*)?|(?P=r)\.headers\.pop\([ \t]*["\x27]Authorization["\x27][^\n]*\))[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=)(?!^(?P=mi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=mi)(?P=r)\.add_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?P=r)\.add_unredirected_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?P=r)\.headers[ \t]*=).){0,1200}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.add_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.add_unredirected_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers[ \t]*=)[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' message: | Bearer authorization stored by urllib Request.add_unredirected_header remains - active even when the ordinary headers mapping is cleared, replaced, popped, - or overwritten. A later resolver-backed dispatch after a separate URL safety - preflight still carries the unredirected credential and can repeat DNS lookup. - Use remove_header to remove both stores, or connect through the validated - address set. + active when the ordinary headers mapping is cleared, emptied, or has its + Authorization entry popped. A later resolver-backed dispatch after a separate + URL safety preflight still carries the unredirected credential and can repeat + DNS lookup unless a normal Authorization value overrides it, remove_header + removes both stores, or the request/destination provenance is replaced. [CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition] [CWE-918 - Server-Side Request Forgery] severity: HIGH From 58eb981108edb53b42f30dfeb4b6b20527d295de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:14:16 +0900 Subject: [PATCH 088/105] test(sast): verify urllib header precedence and destination binding --- ...rer_dns_toctou_unredirected_persistence.py | 75 ++++++++++++++++++- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/tests/test_bearer_dns_toctou_unredirected_persistence.py b/tests/test_bearer_dns_toctou_unredirected_persistence.py index d78eecae..e9795baa 100644 --- a/tests/test_bearer_dns_toctou_unredirected_persistence.py +++ b/tests/test_bearer_dns_toctou_unredirected_persistence.py @@ -1,6 +1,7 @@ """Regression coverage for urllib's separate unredirected header store.""" import re +import urllib.request from pathlib import Path from scanner.cli.appguardrail import SCAN_RULES, _scan_file @@ -83,14 +84,13 @@ def test_headers_pop_does_not_remove_unredirected_bearer(tmp_path): assert [finding["rule_id"] for finding in findings] == [_RULE] -def test_regular_authorization_overwrite_does_not_remove_unredirected_bearer(tmp_path): +def test_regular_authorization_overwrite_suppresses_unredirected_bearer(tmp_path): source = _prefix() + """\ req.add_unredirected_header("Authorization", f"Bearer {api_key}") req.headers["Authorization"] = "Basic dXNlcjpwYXNz" return urllib.request.urlopen(req, timeout=5) """ - findings = _family_findings(tmp_path, source) - assert [finding["rule_id"] for finding in findings] == [_RULE] + assert not _family_findings(tmp_path, source) def test_multiline_unredirected_bearer_survives_headers_clear(tmp_path): @@ -114,3 +114,72 @@ def test_remove_header_clears_both_header_stores(tmp_path): return urllib.request.urlopen(req, timeout=5) """ assert not _family_findings(tmp_path, source) + + +def test_normal_authorization_overrides_unredirected_at_runtime(): + req = urllib.request.Request("https://example.test/") + req.add_unredirected_header("Authorization", "Bearer secret") + req.headers["Authorization"] = "Basic dXNlcjpwYXNz" + assert dict(req.header_items())["Authorization"] == "Basic dXNlcjpwYXNz" + + +def test_pop_reveals_unredirected_authorization_at_runtime(): + req = urllib.request.Request("https://example.test/") + req.add_unredirected_header("Authorization", "Bearer secret") + req.headers["Authorization"] = "Basic dXNlcjpwYXNz" + req.headers.pop("Authorization") + assert dict(req.header_items())["Authorization"] == "Bearer secret" + + +def test_clear_reveals_unredirected_authorization_at_runtime(): + req = urllib.request.Request("https://example.test/") + req.add_unredirected_header("Authorization", "Bearer secret") + req.headers["Authorization"] = "Basic dXNlcjpwYXNz" + req.headers.clear() + assert dict(req.header_items())["Authorization"] == "Bearer secret" + + +def test_fixed_one_line_request_with_endpoint_only_in_header_is_not_bound(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request("https://fixed.example/api", headers={"X-Endpoint": endpoint}) + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + assert not _family_findings(tmp_path, source) + + +def test_fixed_multiline_request_with_endpoint_only_in_header_is_not_bound(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + "https://fixed.example/api", + headers={"X-Endpoint": endpoint}, + ) + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + assert not _family_findings(tmp_path, source) + + +def test_keyword_request_destination_remains_bound(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(url=endpoint) + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_RULE] From 196a3c523d0719e48f65a99375044188c332f922 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:14:39 +0900 Subject: [PATCH 089/105] test(sast): enforce dynamic Request destination binding --- ...arer_dns_toctou_request_state_mutations.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_bearer_dns_toctou_request_state_mutations.py b/tests/test_bearer_dns_toctou_request_state_mutations.py index 621564e6..ee1f6122 100644 --- a/tests/test_bearer_dns_toctou_request_state_mutations.py +++ b/tests/test_bearer_dns_toctou_request_state_mutations.py @@ -13,6 +13,7 @@ "python-bearer-preflight-dns-toctou-multiline-constructor", "python-bearer-preflight-dns-toctou-multiline-header-mutation", _DYNAMIC, + "python-bearer-preflight-dns-toctou-unredirected-header-persistence", } @@ -179,3 +180,49 @@ def test_dynamic_bearer_reviewed_opener_request_replacement_is_sanitized(tmp_pat return opener.open(req, timeout=5) """ assert not _scan_source(tmp_path, source) + + +def test_dynamic_fixed_one_line_request_merely_mentioning_endpoint_is_not_bound(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request("https://fixed.example/api", headers={"X-Endpoint": endpoint}) + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_dynamic_fixed_multiline_request_merely_mentioning_endpoint_is_not_bound(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + "https://fixed.example/api", + headers={"X-Endpoint": endpoint}, + ) + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_dynamic_keyword_request_destination_remains_detectable(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(url=endpoint) + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_DYNAMIC] From 45c1bd45595e0b5aea4153790b8d071d888e6ffe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:15:41 +0900 Subject: [PATCH 090/105] docs(security): correct urllib credential precedence --- docs/TRACEABILITY.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 1daecaba..70944b18 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -53,15 +53,15 @@ Current protected-branch evidence keeps those controls distinct: PR #924 supplie Security issue #892 and merged PR #898 establish the runtime defect and repair: a bearer-authenticated control-plane push first validated a URL, then `urllib` made a second DNS decision during connection; the protected repair uses DNS-pinned HTTPS so the actual connection uses the validated public address set while retaining the hostname for TLS identity verification. -PR #1080 is the integration record for the independent scanner obligation. The detector family keeps six executable rule identities rather than overloading one rule ID or forcing materially different syntax/state forms through one expression. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. `python-bearer-preflight-dns-toctou-multiline-constructor` and `python-bearer-preflight-dns-toctou-multiline-header-mutation` cover the corresponding reviewed line-wrapped credential forms without changing the defect identity or broadening the transport boundary. `python-bearer-preflight-dns-toctou-dynamic-bearer-replacement` preserves detection when a local replacement variable is provably Bearer-valued before being applied to the tracked request. `python-bearer-preflight-dns-toctou-unredirected-header-persistence` models urllib's separate `unredirected_hdrs` credential store so ordinary `headers`-mapping mutations cannot falsely sanitize an Authorization value that remains active there. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. +PR #1080 is the integration record for the independent scanner obligation. The detector family keeps six executable rule identities rather than overloading one rule ID or forcing materially different syntax/state forms through one expression. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. `python-bearer-preflight-dns-toctou-multiline-constructor` and `python-bearer-preflight-dns-toctou-multiline-header-mutation` cover the corresponding reviewed line-wrapped credential forms without changing the defect identity or broadening the transport boundary. `python-bearer-preflight-dns-toctou-dynamic-bearer-replacement` preserves detection when a local replacement variable is provably Bearer-valued before being applied to the tracked request. `python-bearer-preflight-dns-toctou-unredirected-header-persistence` models urllib's separate `unredirected_hdrs` credential store and the normal-header precedence used by `Request.header_items()`: clearing/emptying/popping the ordinary Authorization entry can expose a still-live unredirected Bearer value, while a same-name normal Authorization entry overrides that unredirected value at dispatch. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. -The multiline companion rules inherit the same destination, request-identity, credential-state, and reachability barriers as their direct-layout family members. Before Request construction, replacing the validated URL or URL-derived endpoint with an unrelated destination breaks destination provenance. After Request construction, reassigning the endpoint variable alone does not sanitize the already-bound Request; before dispatch, tracked-request replacement, unconditional same-path `return`/`raise`, Authorization removal, or a statically non-Bearer Authorization overwrite terminates the corresponding live request path when that operation actually targets the credential store that carries Authorization. Self-derived endpoint/request state remains eligible before it is bound, and a later supported Bearer restoration can re-establish credential provenance. `tests/test_bearer_dns_toctou_multiline_regressions.py` executes these boundaries through production `_scan_file`, including paired vulnerable, sanitized, dead-sink, self-derived, fully multiline constructor-plus-mutation, and remove-then-restore cases. +The multiline companion rules inherit the same destination, request-identity, credential-state, and reachability barriers as their direct-layout family members. Before Request construction, replacing the validated URL or URL-derived endpoint with an unrelated destination breaks destination provenance. After Request construction, reassigning the endpoint variable alone does not sanitize the already-bound Request; before dispatch, tracked-request replacement, unconditional same-path `return`/`raise`, Authorization removal, or a statically non-Bearer Authorization overwrite terminates the corresponding live request path when that operation actually targets the credential store that carries or overrides Authorization. Self-derived endpoint/request state remains eligible before it is bound, and a later supported Bearer restoration can re-establish credential provenance. `tests/test_bearer_dns_toctou_multiline_regressions.py` executes these boundaries through production `_scan_file`, including paired vulnerable, sanitized, dead-sink, self-derived, fully multiline constructor-plus-mutation, and remove-then-restore cases. -Request construction is destination- and credential-bound rather than token-text-bound. The tracked endpoint must be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. For the primary Request-credential path, `Authorization: Bearer ...` counts only when it is in the supported direct `headers=` argument at Request-call argument indentation; a nested `headers=` expression inside `data=` or another argument is not HTTP-header evidence. Ordinary direct Request keyword arguments such as `data=` and `method=` may appear between the tracked URL argument and the direct `headers=` argument without breaking the path. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. +Request construction is destination- and credential-bound rather than token-text-bound. Every family member, including the dynamic-Bearer and unredirected-header companions, requires the tracked endpoint to be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. For the primary Request-credential path, `Authorization: Bearer ...` counts only when it is in the supported direct `headers=` argument at Request-call argument indentation; a nested `headers=` expression inside `data=` or another argument is not HTTP-header evidence. Ordinary direct Request keyword arguments such as `data=` and `method=` may appear between the tracked URL argument and the direct `headers=` argument without breaking the path. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. -Credential-removal and request-replacement barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")` before dispatch is a universal credential barrier for urllib Request header storage unless a supported later Bearer mutation re-establishes credential flow. Mutations limited to the ordinary `req.headers` mapping, including `headers.clear()`, `headers = {}`, `headers.pop("Authorization", ...)`, or regular-map Authorization overwrite, terminate provenance only when the tracked credential resides in that mapping; they do not remove a Bearer credential previously installed through `add_unredirected_header`, which lives in `unredirected_hdrs`. Removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. Likewise, a same-branch replacement of the tracked request breaks the original path whenever the replacement destination is not the tracked endpoint, regardless of whether the replacement itself carries Bearer credentials. A rebuild that still targets the tracked endpoint remains detectable when credential provenance also remains, while an unauthenticated same-endpoint rebuild is a credential break. An opposite-branch replacement cannot sanitize a sibling branch that still dispatches the original Bearer request. The production review-boundary regressions preserve these paired cases together with first-positional and keyword-URL positives so later regex changes cannot recover precision by creating silent false negatives. +Credential-removal and request-replacement barriers are path-sensitive for the supported nested layouts. A same-branch `remove_header("Authorization")` before dispatch is a universal credential barrier for urllib Request header storage unless a supported later Bearer mutation re-establishes credential flow. For a credential stored in the ordinary `req.headers` mapping, `headers.clear()`, `headers = {}`, `headers.pop("Authorization", ...)`, and a non-Bearer same-name overwrite terminate Bearer provenance. For a Bearer value installed through `add_unredirected_header`, the storage and transmission semantics differ: ordinary-map clear/empty/pop operations do not delete `unredirected_hdrs` and can leave or reveal that Bearer value, whereas a normal same-name Authorization entry takes precedence in `Request.header_items()` and therefore suppresses transmission of the unredirected Bearer until that overriding normal entry is removed. `remove_header("Authorization")` removes both stores. Removal confined to a different branch does not sanitize a dispatch on the branch where the credential remains. Likewise, a same-branch replacement of the tracked request breaks the original path whenever the replacement destination is not the tracked endpoint, regardless of whether the replacement itself carries Bearer credentials. A rebuild that still targets the tracked endpoint remains detectable when credential provenance also remains, while an unauthenticated same-endpoint rebuild is a credential break. An opposite-branch replacement cannot sanitize a sibling branch that still dispatches the original Bearer request. The production review-boundary regressions preserve these paired cases together with first-positional and keyword-URL positives so later regex changes cannot recover precision by creating silent false negatives. -Post-construction Bearer mutation is an independent credential source for the header-mutation subrule and does not require a preceding removal. `add_header("Authorization", "Bearer ...")`, `add_unredirected_header(...)`, or `headers["Authorization"] = "Bearer ..."` can therefore establish the credential-bearing DNS-re-resolution path on an initially unauthenticated tracked Request; the same forms also restore the path after an explicit removal. Because `add_unredirected_header` stores its value separately from `headers`, the unredirected-header persistence companion preserves a finding after ordinary-header clear/replacement/pop/overwrite and treats `remove_header("Authorization")` as the supported operation that clears both stores. `tests/test_bearer_dns_toctou_unredirected_persistence.py` fixes that store-aware contract through production `_scan_file`, including direct and multiline credential installation plus a `remove_header` negative. The mutation path still requires the validated destination and request identity to remain live through the reviewed re-resolving sink, and it breaks provenance when the validated URL/endpoint is replaced before Request construction, the tracked request is replaced before dispatch, a non-Bearer authorization actually replaces the active credential, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so false-positive barriers do not become silent false-negative sanitizers. Bearer-looking text nested in request data is not used as HTTP-header evidence. +Post-construction Bearer mutation is an independent credential source for the header-mutation subrule and does not require a preceding removal. `add_header("Authorization", "Bearer ...")`, `add_unredirected_header(...)`, or `headers["Authorization"] = "Bearer ..."` can therefore establish the credential-bearing DNS-re-resolution path on an initially unauthenticated tracked Request; the same forms also restore the path after an explicit removal. The unredirected-header persistence companion is intentionally narrower: it preserves a finding after ordinary-header clear, empty-map replacement, or Authorization pop because those operations leave the unredirected store live, but it stops when a normal same-name Authorization value overrides that store and treats `remove_header("Authorization")` as the supported operation that clears both stores. `tests/test_bearer_dns_toctou_unredirected_persistence.py` fixes that contract through production `_scan_file` and runtime `urllib.request.Request.header_items()` assertions, including normal-header override, pop/clear re-exposure, direct and multiline credential installation, destination-binding negatives, and a `remove_header` negative. The mutation path still requires the validated destination and request identity to remain live through the reviewed re-resolving sink, and it breaks provenance when the validated URL/endpoint is replaced before Request construction, the tracked request is replaced before dispatch, a non-Bearer authorization actually replaces the active credential, or an unconditional same-path `return`/`raise` makes the sink unreachable. Self-derived endpoint updates and request-preserving assignments remain positive so false-positive barriers do not become silent false-negative sanitizers. Bearer-looking text nested in request data is not used as HTTP-header evidence. Simple self-derived assignments preserve the tracked path when an executable tracked identifier appears before any string literal or comment on the right-hand side, including `endpoint = endpoint + ...`, URL-derived endpoint updates, and `req = req`. An assignment where the tracked name occurs only as quoted data, such as `endpoint = choose("endpoint")` or `req = choose("req")`, is a provenance break and must not create a HIGH finding. More general wrapper/helper-mediated value flow that places a literal before the tracked identifier is outside this bounded regex detector and requires separate executable evidence rather than speculative flow inference. Paired production `_scan_file` regressions keep both the self-derived positives and quoted-name replacement negatives stable. From 44ba203d14043e76a57f395091ee03c86887b593 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:15:54 +0900 Subject: [PATCH 091/105] docs(security): correct unredirected header precedence --- CHANGELOG.d/892-bearer-dns-toctou-detector.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.d/892-bearer-dns-toctou-detector.md b/CHANGELOG.d/892-bearer-dns-toctou-detector.md index 90489bd6..2f271aec 100644 --- a/CHANGELOG.d/892-bearer-dns-toctou-detector.md +++ b/CHANGELOG.d/892-bearer-dns-toctou-detector.md @@ -1,5 +1,5 @@ ## Security - Add the HIGH built-in Bearer DNS-rebinding TOCTOU detector family for Python `urllib` flows that validate a URL before dispatch but allow the network client to make a second DNS decision. The source-backed regression corpus preserves the pre-PR #898 vulnerable flow and the protected DNS-pinned HTTPS repair for security issue #892. -- Track post-construction request state instead of treating the original constructor as permanently authoritative: opaque/dynamic Authorization replacement, header mapping replacement/clearing, and unrelated `Request.full_url` mutation terminate stale credential or destination provenance. A narrowly scoped companion rule keeps provably Bearer-valued variable replacements detectable, while self-derived destination mutations remain positive. -- Model urllib credential storage precisely for `add_unredirected_header`: Authorization stored in `unredirected_hdrs` survives ordinary `req.headers` clear/replacement/pop/overwrite operations, so the detector retains the credential-bearing TOCTOU path until `remove_header("Authorization")` or request replacement actually removes that store. Production scanner regressions preserve direct and multiline positive cases plus the universal-removal negative. +- Track post-construction request state instead of treating the original constructor as permanently authoritative: opaque/dynamic Authorization replacement, header mapping replacement/clearing, and unrelated `Request.full_url` mutation terminate stale credential or destination provenance when those operations affect the active credential/destination. A narrowly scoped companion rule keeps provably Bearer-valued variable replacements detectable, while self-derived destination mutations remain positive. All companion rules bind the validated endpoint only when it is the actual first positional Request URL or `url=...`, not when it merely appears in another argument. +- Model urllib credential storage and precedence precisely for `add_unredirected_header`: a Bearer value in `unredirected_hdrs` survives ordinary `req.headers.clear()`, empty-map replacement, or Authorization pop, but a same-name normal Authorization entry overrides it in `Request.header_items()` at dispatch. `remove_header("Authorization")` removes both stores. Production scanner regressions and runtime `urllib.request.Request` assertions preserve clear/pop re-exposure, normal-header override, direct/multiline positives, destination-binding negatives, and the universal-removal negative. From 5c91e6194c03a99ef74577a6ae701c96c4461b11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:01:00 +0900 Subject: [PATCH 092/105] test(sast): lock dynamic destination provenance regressions --- ...arer_dns_toctou_request_state_mutations.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_bearer_dns_toctou_request_state_mutations.py b/tests/test_bearer_dns_toctou_request_state_mutations.py index ee1f6122..c4ca056b 100644 --- a/tests/test_bearer_dns_toctou_request_state_mutations.py +++ b/tests/test_bearer_dns_toctou_request_state_mutations.py @@ -226,3 +226,49 @@ def deliver(url, api_key): """ findings = _scan_source(tmp_path, source) assert [finding["rule_id"] for finding in findings] == [_DYNAMIC] + + +def test_dynamic_validated_url_replacement_before_endpoint_breaks_provenance(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + url = "https://fixed.example" + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_dynamic_endpoint_replacement_before_request_breaks_provenance(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + endpoint = "https://fixed.example/api/v1/scans" + req = urllib.request.Request(endpoint) + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_dynamic_self_derived_endpoint_reassignment_preserves_provenance(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + endpoint = endpoint + "?retry=1" + req = urllib.request.Request(endpoint) + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_DYNAMIC] From eff3743f22b89b24d665f6240e6f817eb208e7ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:01:56 +0900 Subject: [PATCH 093/105] fix(sast): terminate stale dynamic destination provenance --- scanner/rules/ssrf_bearer_dynamic.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_dynamic.yml b/scanner/rules/ssrf_bearer_dynamic.yml index d6b1b551..8850f695 100644 --- a/scanner/rules/ssrf_bearer_dynamic.yml +++ b/scanner/rules/ssrf_bearer_dynamic.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-dynamic-bearer-replacement patterns: - - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,6000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\)|[ \t]*\n(?:^[ \t]*\n|^(?P=bi)[ \t]+\#[^\n]*\n){0,8}^(?P=bi)[ \t]+(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,1200}?^(?P=bi)(?:(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=v)\b|(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=v)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,80}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' + - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,6000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=u)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=u)\b)).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=ep)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=ep)|(?P=u))\b)).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\)|[ \t]*\n(?:^[ \t]*\n|^(?P=bi)[ \t]+\#[^\n]*\n){0,8}^(?P=bi)[ \t]+(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,1200}?^(?P=bi)(?:(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=v)\b|(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=v)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,80}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' message: | A locally provable Bearer replacement is applied to a live urllib Request after a separate URL-safety preflight, and that request is dispatched through a From 1b073fa2052b1889f9eafdaa965743f44f329f5a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:06:29 +0900 Subject: [PATCH 094/105] test(sast): preserve self-derived validated-url provenance --- ..._bearer_dns_toctou_request_state_mutations.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_bearer_dns_toctou_request_state_mutations.py b/tests/test_bearer_dns_toctou_request_state_mutations.py index c4ca056b..6156ef49 100644 --- a/tests/test_bearer_dns_toctou_request_state_mutations.py +++ b/tests/test_bearer_dns_toctou_request_state_mutations.py @@ -272,3 +272,19 @@ def deliver(url, api_key): """ findings = _scan_source(tmp_path, source) assert [finding["rule_id"] for finding in findings] == [_DYNAMIC] + + +def test_dynamic_self_derived_validated_url_reassignment_preserves_provenance(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + url = url.rstrip("/") + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + replacement = f"Bearer {api_key}" + req.headers["Authorization"] = replacement + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_DYNAMIC] From 9f1cb8bf0c3693a2ccde15bc6c8a85852dfa3f2e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:09:35 +0900 Subject: [PATCH 095/105] test(sast): lock inverse-condition bearer path compatibility --- ...dns_toctou_inverse_condition_regression.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_inverse_condition_regression.py diff --git a/tests/test_bearer_dns_toctou_inverse_condition_regression.py b/tests/test_bearer_dns_toctou_inverse_condition_regression.py new file mode 100644 index 00000000..c87defef --- /dev/null +++ b/tests/test_bearer_dns_toctou_inverse_condition_regression.py @@ -0,0 +1,96 @@ +"""Control-flow compatibility regressions for Bearer DNS TOCTOU detectors.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_FAMILY = { + "python-bearer-preflight-dns-toctou", + "python-bearer-preflight-dns-toctou-header-mutation", + "python-bearer-preflight-dns-toctou-multiline-constructor", + "python-bearer-preflight-dns-toctou-multiline-header-mutation", + "python-bearer-preflight-dns-toctou-dynamic-bearer-replacement", + "python-bearer-preflight-dns-toctou-unredirected-header-persistence", +} + + +def _family_findings(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def test_inverse_boolean_guard_cannot_borrow_one_line_bearer_state(tmp_path): + source = """\ +def deliver(url, api_key, enabled): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + if enabled: + req.add_header("Authorization", f"Bearer {api_key}") + if not enabled: + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert _family_findings(tmp_path, source) == [] + + +def test_inverse_boolean_guard_cannot_borrow_multiline_bearer_state(tmp_path): + source = """\ +def deliver(url, api_key, enabled): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + if enabled: + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + if not enabled: + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert _family_findings(tmp_path, source) == [] + + +def test_guarded_bearer_mutation_still_reaches_outer_dispatch(tmp_path): + source = """\ +def deliver(url, api_key, enabled): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + if enabled: + req.add_header("Authorization", f"Bearer {api_key}") + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [ + "python-bearer-preflight-dns-toctou-header-mutation" + ] + + +def test_guarded_multiline_bearer_mutation_still_reaches_outer_dispatch(tmp_path): + source = """\ +def deliver(url, api_key, enabled): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + if enabled: + req.add_header( + "Authorization", + f"Bearer {api_key}", + ) + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [ + "python-bearer-preflight-dns-toctou-multiline-header-mutation" + ] From 217e90ea021525aecf991ba700e7485ffa6e4cbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:15:24 +0900 Subject: [PATCH 096/105] fix(sast): reject inverse-guard bearer branch joins --- scanner/rules/ssrf_bearer_restore.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scanner/rules/ssrf_bearer_restore.yml b/scanner/rules/ssrf_bearer_restore.yml index fde433bf..ecd80044 100644 --- a/scanner/rules/ssrf_bearer_restore.yml +++ b/scanner/rules/ssrf_bearer_restore.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=flowindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]+)).){0,1500}?(?:^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P(?P=bodyindent)[ \t]*)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=openerindent)(?:return|raise)\b)(?!^(?P=openerindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=openerindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?>(?:(?=(?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=initialargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=initialargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)))(?P))?)[ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent))if[ \t]+(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*:[^\n]*\n(?:(?:^(?P=oppguardindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P(?P=oppguardindent)[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)[^\n]*\)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=oppguardindent)if[ \t]+not[ \t]+(?P=oppcond)\b).){0,1500}?^(?P=oppguardindent)if[ \t]+not[ \t]+(?P=oppcond)\b[^\n]*:[^\n]*\n(?:(?!^(?P=oppguardindent)\S).){0,800}?^(?P=oppmutindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=req)\b)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,3000}?^(?P(?P=bodyindent)[ \t]*)(?P(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\])[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1500}?^(?P=flowindent))?(?(initialbearer)(?(authremoval)(?=)|(?!))|(?=))(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=flowindent)(?:return|raise)\b)(?!^(?P=flowindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=flowindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]+)).){0,1500}?(?:^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P(?P=bodyindent)[ \t]*)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=openerindent)(?:return|raise)\b)(?!^(?P=openerindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b)).){0,1000}?^(?P=openerindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' message: | A bearer-authenticated urllib request is dispatched after a separate URL-safety preflight after Bearer authorization is added or restored on the live request. @@ -13,4 +13,4 @@ rules: languages: [python] prefilter: [_is_safe_url, urllib.request.Request] cwe: [CWE-367, CWE-918] - owasp: [A10:2021] + owasp: [A10:2021] \ No newline at end of file From b03e4523728fb6cfb4fae7bccb3d28f7a0227b32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:16:11 +0900 Subject: [PATCH 097/105] fix(sast): reject inverse-guard multiline branch joins --- scanner/rules/ssrf_bearer_multiline.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scanner/rules/ssrf_bearer_multiline.yml b/scanner/rules/ssrf_bearer_multiline.yml index 7e4d2409..96e516a5 100644 --- a/scanner/rules/ssrf_bearer_multiline.yml +++ b/scanner/rules/ssrf_bearer_multiline.yml @@ -15,7 +15,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou-multiline-header-mutation patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=reqargindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]*\)[^\n]*\n)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,60}?^(?P[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*(?:\n[ \t]*)?\][ \t]*=[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n)?[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=mutindent)(?:return|raise)\b)(?!^(?P=mutindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=mutindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,6000}?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\)[^\n]*\n|[ \t]*\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=reqargindent)(?!headers\b)[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]*\)[^\n]*\n)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent))if[ \t]+(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*:[^\n]*\n(?:(?:^(?P=oppguardindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P(?P=oppguardindent)[ \t]+)(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27](?:(?!^(?P=oppguardindent)if[ \t]+not[ \t]+(?P=oppcond)\b).){0,1500}?^(?P=oppguardindent)if[ \t]+not[ \t]+(?P=oppcond)\b[^\n]*:[^\n]*\n(?:(?!^(?P=oppguardindent)\S).){0,800}?^(?P=oppmutindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=req)\b)(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n){0,60}?^(?P[ \t]+)(?:(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|(?P=req)\.headers[ \t]*\[[ \t]*(?:\#[^\n]*)?\n[ \t]*(?!\#)["\x27]Authorization["\x27][ \t]*(?:\n[ \t]*)?\][ \t]*=[ \t]*(?:\([ \t]*(?:\#[^\n]*)?\n)?[ \t]*(?!\#)f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27])(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b)(?!^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:elif|else|except|finally|case)\b)(?!^(?P=mutindent)(?:return|raise)\b)(?!^(?P=mutindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=mutindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27]| del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27]| (?P=req)\.headers\.clear[ \t]*\(| (?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+| (?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*| (?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+| (?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+))[^\n]*\n){0,80}?(?:^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^[ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,800}?^[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b)' message: | A Bearer credential added to a validated urllib request is dispatched through a resolver-backed transport after the preflight. This companion rule covers supported @@ -26,4 +26,4 @@ rules: languages: [python] prefilter: [_is_safe_url, urllib.request.Request] cwe: [CWE-367, CWE-918] - owasp: [A10:2021] + owasp: [A10:2021] \ No newline at end of file From b231d6a8b5f8ebe20279312ac432ea3420c12f0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:18:22 +0900 Subject: [PATCH 098/105] docs(traceability): record inverse-guard path boundary --- docs/TRACEABILITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 70944b18..54663679 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -53,9 +53,9 @@ Current protected-branch evidence keeps those controls distinct: PR #924 supplie Security issue #892 and merged PR #898 establish the runtime defect and repair: a bearer-authenticated control-plane push first validated a URL, then `urllib` made a second DNS decision during connection; the protected repair uses DNS-pinned HTTPS so the actual connection uses the validated public address set while retaining the hostname for TLS identity verification. -PR #1080 is the integration record for the independent scanner obligation. The detector family keeps six executable rule identities rather than overloading one rule ID or forcing materially different syntax/state forms through one expression. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. `python-bearer-preflight-dns-toctou-multiline-constructor` and `python-bearer-preflight-dns-toctou-multiline-header-mutation` cover the corresponding reviewed line-wrapped credential forms without changing the defect identity or broadening the transport boundary. `python-bearer-preflight-dns-toctou-dynamic-bearer-replacement` preserves detection when a local replacement variable is provably Bearer-valued before being applied to the tracked request. `python-bearer-preflight-dns-toctou-unredirected-header-persistence` models urllib's separate `unredirected_hdrs` credential store and the normal-header precedence used by `Request.header_items()`: clearing/emptying/popping the ordinary Authorization entry can expose a still-live unredirected Bearer value, while a same-name normal Authorization entry overrides that unredirected value at dispatch. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. +PR #1080 is the integration record for the independent scanner obligation. The detector family keeps six executable rule identities rather than overloading one rule ID or forcing materially different syntax/state forms through one expression. `python-bearer-preflight-dns-toctou` binds the same-function source-derived path `_is_safe_url(url)` preflight -> endpoint derived from that URL -> executable `urllib.request.Request` whose direct `headers=` argument carries an `Authorization: Bearer ...` credential -> later reviewed re-resolving urllib dispatch. `python-bearer-preflight-dns-toctou-header-mutation` covers the same validated-destination race when Bearer authorization is added or restored on the still-live tracked request after construction. `python-bearer-preflight-dns-toctou-multiline-constructor` and `python-bearer-preflight-dns-toctou-multiline-header-mutation` cover the corresponding reviewed line-wrapped credential forms without changing the defect identity or broadening the transport boundary. `python-bearer-preflight-dns-toctou-dynamic-bearer-replacement` preserves detection when a local replacement variable is provably Bearer-valued before being applied to the tracked request. `python-bearer-preflight-dns-toctou-unredirected-header-persistence` models urllib's separate `unredirected_hdrs` credential store and the normal-header precedence used by `Request.header_items()`: clearing/emptying/popping the ordinary Authorization entry can expose a still-live unredirected Bearer value, while a same-name normal Authorization entry overrides that unredirected value at dispatch. The historical vulnerable fixture and the reviewed pinned-HTTPS fixed fixture remain regression oracles. Production `_scan_file` negatives preserve the reviewed false-positive boundary: commented-out request/header/dispatch text cannot donate evidence; request construction and dispatch in mutually exclusive branches cannot form one path; a Bearer mutation under a simple `if flag:` cannot donate credential state to a resolver-backed dispatch nested under a later `if not flag:`; unauthenticated urllib delivery, validation without network dispatch, arbitrary custom/pinned opener transports, and evidence split across sibling functions are not this detector family. The inverse-guard veto is deliberately narrow: the same guarded mutation followed directly by an outer unconditional resolver-backed dispatch remains positive. Direct `urllib.request.urlopen` and the reviewed `urllib.request.build_opener(SafeRedirectHandler()).open` flow remain positive network sinks because they can repeat hostname resolution after the separate preflight. -The multiline companion rules inherit the same destination, request-identity, credential-state, and reachability barriers as their direct-layout family members. Before Request construction, replacing the validated URL or URL-derived endpoint with an unrelated destination breaks destination provenance. After Request construction, reassigning the endpoint variable alone does not sanitize the already-bound Request; before dispatch, tracked-request replacement, unconditional same-path `return`/`raise`, Authorization removal, or a statically non-Bearer Authorization overwrite terminates the corresponding live request path when that operation actually targets the credential store that carries or overrides Authorization. Self-derived endpoint/request state remains eligible before it is bound, and a later supported Bearer restoration can re-establish credential provenance. `tests/test_bearer_dns_toctou_multiline_regressions.py` executes these boundaries through production `_scan_file`, including paired vulnerable, sanitized, dead-sink, self-derived, fully multiline constructor-plus-mutation, and remove-then-restore cases. +The multiline companion rules inherit the same destination, request-identity, credential-state, and reachability barriers as their direct-layout family members. Before Request construction, replacing the validated URL or URL-derived endpoint with an unrelated destination breaks destination provenance. After Request construction, reassigning the endpoint variable alone does not sanitize the already-bound Request; before dispatch, tracked-request replacement, unconditional same-path `return`/`raise`, Authorization removal, or a statically non-Bearer Authorization overwrite terminates the corresponding live request path when that operation actually targets the credential store that carries or overrides Authorization. Self-derived endpoint/request state remains eligible before it is bound, and a later supported Bearer restoration can re-establish credential provenance. `tests/test_bearer_dns_toctou_multiline_regressions.py` executes these boundaries through production `_scan_file`, including paired vulnerable, sanitized, dead-sink, self-derived, fully multiline constructor-plus-mutation, and remove-then-restore cases. `tests/test_bearer_dns_toctou_inverse_condition_regression.py` keeps one-line and multiline inverse-condition negatives paired with outer-fallthrough positives so control-flow precision cannot be recovered by silently dropping executable vulnerable paths. Request construction is destination- and credential-bound rather than token-text-bound. Every family member, including the dynamic-Bearer and unredirected-header companions, requires the tracked endpoint to be the actual `Request` destination through the first positional URL argument or `url=endpoint`; an endpoint mentioned only in another header/argument beside a fixed request URL cannot donate destination provenance. For the primary Request-credential path, `Authorization: Bearer ...` counts only when it is in the supported direct `headers=` argument at Request-call argument indentation; a nested `headers=` expression inside `data=` or another argument is not HTTP-header evidence. Ordinary direct Request keyword arguments such as `data=` and `method=` may appear between the tracked URL argument and the direct `headers=` argument without breaking the path. Replacing the validated URL with an unrelated destination before endpoint derivation breaks preflight provenance, while self-derived transformations of the validated URL preserve it. From 037b8be6ed8be7629284013dcf24bc638ff5ee0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:20:58 +0900 Subject: [PATCH 099/105] docs(changelog): record path-provenance detector repairs --- CHANGELOG.d/892-bearer-dns-toctou-detector.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.d/892-bearer-dns-toctou-detector.md b/CHANGELOG.d/892-bearer-dns-toctou-detector.md index 2f271aec..5667cf16 100644 --- a/CHANGELOG.d/892-bearer-dns-toctou-detector.md +++ b/CHANGELOG.d/892-bearer-dns-toctou-detector.md @@ -2,4 +2,5 @@ - Add the HIGH built-in Bearer DNS-rebinding TOCTOU detector family for Python `urllib` flows that validate a URL before dispatch but allow the network client to make a second DNS decision. The source-backed regression corpus preserves the pre-PR #898 vulnerable flow and the protected DNS-pinned HTTPS repair for security issue #892. - Track post-construction request state instead of treating the original constructor as permanently authoritative: opaque/dynamic Authorization replacement, header mapping replacement/clearing, and unrelated `Request.full_url` mutation terminate stale credential or destination provenance when those operations affect the active credential/destination. A narrowly scoped companion rule keeps provably Bearer-valued variable replacements detectable, while self-derived destination mutations remain positive. All companion rules bind the validated endpoint only when it is the actual first positional Request URL or `url=...`, not when it merely appears in another argument. +- Preserve pre-Request destination and bounded control-flow provenance: unrelated reassignment of the validated URL or its derived endpoint breaks the dynamic-Bearer path, while self-derived transformations remain detectable; a Bearer mutation under `if flag:` cannot donate credential state to a resolver-backed dispatch nested under a later `if not flag:`, while direct fallthrough from that guarded mutation to an outer dispatch remains positive. - Model urllib credential storage and precedence precisely for `add_unredirected_header`: a Bearer value in `unredirected_hdrs` survives ordinary `req.headers.clear()`, empty-map replacement, or Authorization pop, but a same-name normal Authorization entry overrides it in `Request.header_items()` at dispatch. `remove_header("Authorization")` removes both stores. Production scanner regressions and runtime `urllib.request.Request` assertions preserve clear/pop re-exposure, normal-header override, direct/multiline positives, destination-binding negatives, and the universal-removal negative. From 75526a496a9aad0fe974c158da0fe0e7f56cc444 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:30:07 +0900 Subject: [PATCH 100/105] test(ssrf): lock dynamic and unredirected provenance barriers --- ...arer_dns_toctou_latest_state_provenance.py | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_latest_state_provenance.py diff --git a/tests/test_bearer_dns_toctou_latest_state_provenance.py b/tests/test_bearer_dns_toctou_latest_state_provenance.py new file mode 100644 index 00000000..492a75a1 --- /dev/null +++ b/tests/test_bearer_dns_toctou_latest_state_provenance.py @@ -0,0 +1,164 @@ +"""Current-head provenance regressions for Bearer DNS TOCTOU companions.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_DYNAMIC = "python-bearer-preflight-dns-toctou-dynamic-bearer-replacement" +_UNREDIRECTED = "python-bearer-preflight-dns-toctou-unredirected-header-persistence" +_FAMILY = { + "python-bearer-preflight-dns-toctou", + "python-bearer-preflight-dns-toctou-header-mutation", + "python-bearer-preflight-dns-toctou-multiline-constructor", + "python-bearer-preflight-dns-toctou-multiline-header-mutation", + _DYNAMIC, + _UNREDIRECTED, +} + + +def _family_findings(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def _dynamic_prefix() -> str: + return """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) +""" + + +def _unredirected_prefix() -> str: + return """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) +""" + + +def test_dynamic_uppercase_identifier_assignments_do_not_cancel_lowercase_flow(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + URL = "https://fixed.example" + endpoint = url.rstrip("/") + "/api/v1/scans" + ENDPOINT = "https://fixed.example/other" + req = urllib.request.Request(endpoint) + replacement = f"bEaReR {api_key}" + REPLACEMENT = "Basic fixed" + req.add_header("authorization", replacement) + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_DYNAMIC] + + +def test_dynamic_replacement_reassignment_before_mutation_breaks_credential_provenance(tmp_path): + source = _dynamic_prefix() + """\ + replacement = f"Bearer {api_key}" + replacement = "Basic fixed" + req.add_header("Authorization", replacement) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _family_findings(tmp_path, source) + + +def test_dynamic_request_replacement_before_mutation_breaks_destination_provenance(tmp_path): + source = _dynamic_prefix() + """\ + replacement = f"Bearer {api_key}" + req = urllib.request.Request("https://fixed.example/api/v1/scans") + req.add_header("Authorization", replacement) + return urllib.request.urlopen(req, timeout=5) +""" + assert not _family_findings(tmp_path, source) + + +def test_dynamic_self_preserving_reassignments_remain_detectable(tmp_path): + source = _dynamic_prefix() + """\ + replacement = f"Bearer {api_key}" + replacement = replacement.strip() + req = req + req.add_header("Authorization", replacement) + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_DYNAMIC] + + +def test_unredirected_validated_url_replacement_before_endpoint_breaks_provenance(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + url = "https://fixed.example" + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request(endpoint) + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + assert not _family_findings(tmp_path, source) + + +def test_unredirected_endpoint_replacement_before_request_breaks_provenance(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + endpoint = "https://fixed.example/api/v1/scans" + req = urllib.request.Request(endpoint) + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + assert not _family_findings(tmp_path, source) + + +def test_unredirected_request_replacement_before_credential_breaks_provenance(tmp_path): + source = _unredirected_prefix() + """\ + req = urllib.request.Request("https://fixed.example/api/v1/scans") + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + return urllib.request.urlopen(req, timeout=5) +""" + assert not _family_findings(tmp_path, source) + + +def test_unredirected_fixed_full_url_after_credential_breaks_provenance(tmp_path): + source = _unredirected_prefix() + """\ + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + req.full_url = "https://fixed.example/api/v1/scans" + return urllib.request.urlopen(req, timeout=5) +""" + assert not _family_findings(tmp_path, source) + + +def test_unredirected_self_derived_state_remains_detectable(tmp_path): + source = """\ +def deliver(url, api_key): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + endpoint = endpoint + "?retry=1" + req = urllib.request.Request(endpoint) + req = req + req.add_unredirected_header("Authorization", f"Bearer {api_key}") + req.headers.clear() + req.full_url = endpoint + "&source=retry" + return urllib.request.urlopen(req, timeout=5) +""" + findings = _family_findings(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_UNREDIRECTED] From 38be651a2e369087bac33f318f20ac0098eb2990 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:33:10 +0900 Subject: [PATCH 101/105] fix(ssrf): track dynamic bearer state before mutation --- scanner/rules/ssrf_bearer_dynamic.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_dynamic.yml b/scanner/rules/ssrf_bearer_dynamic.yml index 8850f695..ea182b1f 100644 --- a/scanner/rules/ssrf_bearer_dynamic.yml +++ b/scanner/rules/ssrf_bearer_dynamic.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-dynamic-bearer-replacement patterns: - - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,6000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=u)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=u)\b)).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=ep)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=ep)|(?P=u))\b)).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\)|[ \t]*\n(?:^[ \t]*\n|^(?P=bi)[ \t]+\#[^\n]*\n){0,8}^(?P=bi)[ \t]+(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,1200}?^(?P=bi)(?:(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?P=v)\b|(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?P=v)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,80}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' + - pattern-regex: '(?ms)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,6000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=u)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=u)\b)).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=ep)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=ep)|(?P=u))\b)).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\)|[ \t]*\n(?:^[ \t]*\n|^(?P=bi)[ \t]+\#[^\n]*\n){0,8}^(?P=bi)[ \t]+(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=r)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=r)\b))(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b)).){0,2000}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?:f?["\x27](?i:Bearer)[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27](?i:Bearer)[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=v)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=v)\b))(?!^(?P=bi)(?P=r)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=r)\b))(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b)).){0,1200}?^(?P=bi)(?:(?P=r)\.headers[ \t]*\[[ \t]*["\x27](?i:Authorization)["\x27][ \t]*\][ \t]*=[ \t]*(?P=v)\b|(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27](?i:Authorization)["\x27][ \t]*,[ \t]*(?P=v)\b[^\n\)]*\))[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27](?i:Authorization)["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.(?:add_header|add_unredirected_header)\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,80}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=bi)(?P=r)\.headers\.(?:pop|clear)\()(?!^(?P=bi)(?P=r)\.headers[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27](?i:Authorization)["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' message: | A locally provable Bearer replacement is applied to a live urllib Request after a separate URL-safety preflight, and that request is dispatched through a From fd7e76440d4508b761a406a8f2161f6e3d51c16a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:33:35 +0900 Subject: [PATCH 102/105] fix(ssrf): invalidate stale unredirected request provenance --- scanner/rules/ssrf_bearer_unredirected_persistence.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf_bearer_unredirected_persistence.yml b/scanner/rules/ssrf_bearer_unredirected_persistence.yml index f449642e..3ef6afb2 100644 --- a/scanner/rules/ssrf_bearer_unredirected_persistence.yml +++ b/scanner/rules/ssrf_bearer_unredirected_persistence.yml @@ -1,7 +1,7 @@ rules: - id: python-bearer-preflight-dns-toctou-unredirected-header-persistence patterns: - - pattern-regex: '(?ims)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,5000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\)|[ \t]*\n(?:^[ \t]*\n|^(?P=bi)[ \t]+\#[^\n]*\n){0,8}^(?P=bi)[ \t]+(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,2000}?^(?P(?P=bi)[ \t]*)(?P=r)\.add_unredirected_header\([ \t]*(?:\n[ \t]+)?["\x27]Authorization["\x27][ \t]*,[ \t]*(?:\n[ \t]+)?(?:f?["\x27]Bearer[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\)?[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=).){0,1200}?^(?P=mi)(?:(?P=r)\.headers\.clear\(\)|(?P=r)\.headers[ \t]*=[ \t]*\{[ \t]*\}[ \t]*(?:\#[^\n]*)?|(?P=r)\.headers\.pop\([ \t]*["\x27]Authorization["\x27][^\n]*\))[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=)(?!^(?P=mi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=mi)(?P=r)\.add_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?P=r)\.add_unredirected_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=mi)(?P=r)\.headers[ \t]*=).){0,1200}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)[ \t]*=)(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.add_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.add_unredirected_header\([ \t]*["\x27]Authorization["\x27])(?!^(?P=bi)(?P=r)\.headers[ \t]*=)[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' + - pattern-regex: '(?ms)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+\w+[^\n]*:\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b).){0,5000}?^(?P(?P=di)[ \t]+)if[ \t]+not[ \t]+_is_safe_url\([ \t]*(?P\w+)[ \t]*\)[ \t]*:[^\n]*\n(?:^(?P=bi)[ \t]+(?:\#[^\n]*)?\n|^[ \t]*\n){0,8}^(?P=bi)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=u)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=u)\b)).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*(?P=u)\.rstrip\([^\n]*\)[^\n]*\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=ep)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=ep)|(?P=u))\b)).){0,2500}?^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.Request\((?:[ \t]*(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\)|[ \t]*\n(?:^[ \t]*\n|^(?P=bi)[ \t]+\#[^\n]*\n){0,8}^(?P=bi)[ \t]+(?:url[ \t]*=[ \t]*)?(?P=ep)\b[^\n]*\n(?:(?!^(?P=bi)\)[ \t]*(?:\#[^\n]*)?$)[^\n]*\n){0,30}^(?P=bi)\)[ \t]*(?:\#[^\n]*)?)\n(?:(?!^(?P=di)(?:async[ \t]+)?def\b|^(?P=di)class\b)(?!^(?P=bi)(?P=r)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=r)\b))(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b)).){0,2000}?^(?P(?P=bi)[ \t]*)(?P=r)\.add_unredirected_header\([ \t]*(?:\n[ \t]+)?["\x27](?i:Authorization)["\x27][ \t]*,[ \t]*(?:\n[ \t]+)?(?:f?["\x27](?i:Bearer)[ \t]+\{?\w+\}?[^\n"\x27]*["\x27]|["\x27](?i:Bearer)[ \t]+["\x27][ \t]*\+[ \t]*\w+)[^\n]*\)?[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=r)\b))(?!^(?P=mi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b)).){0,1200}?^(?P=mi)(?:(?P=r)\.headers\.clear\(\)|(?P=r)\.headers[ \t]*=[ \t]*\{[ \t]*\}[ \t]*(?:\#[^\n]*)?|(?P=r)\.headers\.pop\([ \t]*["\x27](?i:Authorization)["\x27][^\n]*\))[^\n]*\n(?:(?!^(?P=mi)(?P=r)\.remove_header\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=mi)(?:return|raise)\b)(?!^(?P=mi)(?P=r)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=r)\b))(?!^(?P=mi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))(?!^(?P=mi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27](?i:Authorization)["\x27][ \t]*\][ \t]*=)(?!^(?P=mi)(?P=r)\.add_header\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=mi)(?P=r)\.add_unredirected_header\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=mi)(?P=r)\.headers[ \t]*=).){0,1200}?(?:^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?urllib\.request\.urlopen\([ \t]*(?:url[ \t]*=[ \t]*)?(?P=r)\b|^(?P=bi)(?P\w+)[ \t]*=[ \t]*urllib\.request\.build_opener\([ \t]*SafeRedirectHandler\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bi)(?:return|raise)\b)(?!^(?P=bi)(?P=o)[ \t]*=)(?!^(?P=bi)(?P=r)\.remove_header\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=bi)(?P=r)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=r)\b))(?!^(?P=bi)(?P=r)\.full_url[ \t]*=(?![^\n]*(?:(?P=ep)|(?P=u))\b))(?!^(?P=bi)(?P=r)\.headers[ \t]*\[[ \t]*["\x27](?i:Authorization)["\x27][ \t]*\][ \t]*=)(?!^(?P=bi)(?P=r)\.add_header\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=bi)(?P=r)\.add_unredirected_header\([ \t]*["\x27](?i:Authorization)["\x27])(?!^(?P=bi)(?P=r)\.headers[ \t]*=)[^\n]*\n){0,40}?^(?P=bi)[ \t]*(?:return[ \t]+|\w+[ \t]*=[ \t]*)?(?P=o)\.open\([ \t]*(?:fullurl[ \t]*=[ \t]*)?(?P=r)\b)' message: | Bearer authorization stored by urllib Request.add_unredirected_header remains active when the ordinary headers mapping is cleared, emptied, or has its From 865b0e1a236e04aabfb51b8c0551fb22c5561deb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:00:18 +0900 Subject: [PATCH 103/105] test(sast): lock exhaustive Bearer branch boundaries --- ...ns_toctou_exhaustive_branch_regressions.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_exhaustive_branch_regressions.py diff --git a/tests/test_bearer_dns_toctou_exhaustive_branch_regressions.py b/tests/test_bearer_dns_toctou_exhaustive_branch_regressions.py new file mode 100644 index 00000000..839324ac --- /dev/null +++ b/tests/test_bearer_dns_toctou_exhaustive_branch_regressions.py @@ -0,0 +1,84 @@ +"""Control-flow regressions for exhaustive branch state in the Bearer DNS TOCTOU family.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_PRIMARY = "python-bearer-preflight-dns-toctou" +_FAMILY = { + _PRIMARY, + "python-bearer-preflight-dns-toctou-header-mutation", + "python-bearer-preflight-dns-toctou-multiline-constructor", + "python-bearer-preflight-dns-toctou-multiline-header-mutation", + "python-bearer-preflight-dns-toctou-dynamic-bearer-replacement", + "python-bearer-preflight-dns-toctou-unredirected-header-persistence", +} + + +def _scan_source(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def _direct_bearer_prefix() -> str: + return """\ +def deliver(url, api_key, rotate=False, stop=False): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) +""" + + +def test_exhaustive_if_else_authorization_removal_is_sanitized(tmp_path): + source = _direct_bearer_prefix() + """\ + if rotate: + req.remove_header("Authorization") + else: + req.remove_header("Authorization") + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_partial_authorization_removal_keeps_reachable_bearer_path(tmp_path): + source = _direct_bearer_prefix() + """\ + if rotate: + req.remove_header("Authorization") + else: + pass + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + +def test_exhaustive_if_else_termination_makes_following_sink_dead(tmp_path): + source = _direct_bearer_prefix() + """\ + if stop: + return None + else: + raise RuntimeError("stopped") + return urllib.request.urlopen(req, timeout=5) +""" + assert not _scan_source(tmp_path, source) + + +def test_partial_termination_keeps_fallthrough_sink_reachable(tmp_path): + source = _direct_bearer_prefix() + """\ + if stop: + return None + else: + pass + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] From 678c77d6cfb7e8f40ce2b53f1de5b07b839bd95c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:06:52 +0900 Subject: [PATCH 104/105] fix(sast): model exhaustive Bearer branch state --- scanner/rules/ssrf.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/rules/ssrf.yml b/scanner/rules/ssrf.yml index 3aa55c1c..051fe96c 100644 --- a/scanner/rules/ssrf.yml +++ b/scanner/rules/ssrf.yml @@ -14,7 +14,7 @@ rules: owasp: [A10:2021] - id: python-bearer-preflight-dns-toctou patterns: - - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers\.clear[ \t]*\([ \t]*\)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b)[^\n]*|(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers\.clear[ \t]*\([ \t]*\)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+)).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers\.clear[ \t]*\([ \t]*\)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+)).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)' + - pattern-regex: '(?imsx)^(?P[ \t]*)(?:async[ \t]+)?def[ \t]+[A-Za-z_][A-Za-z0-9_]*[^\n]*:[ \t]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).)*?^(?P(?P=defindent)[ \t]+)if[ \t]+not[ \t]+_is_safe_url[ \t]*\([ \t]*(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)[ \t]*:[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]+(?:return|raise)\b[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=url)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=url)\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?P=url)\.rstrip[ \t]*\([^\n]*\)[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=endpoint)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b))[^\n]*\n)*?^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\([ \t]*(?:(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?=[ \t]*,(?:[ \t]*(?:data|method|origin_req_host|unverifiable)[ \t]*=[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|None|True|False|b?["\x27][^"\x27\n]*["\x27]|\d+)[ \t]*,){0,4}[ \t]*headers[ \t]*=[ \t]*\{[^\n\#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^\n]*\)[ \t]*(?:\#[^\n]*)?$|\n(?P[ \t]+)(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b[^\n]*\n(?:(?:^(?P=requestargindent)(?!headers\b)(?![^\n]*["\x27]Authorization["\x27])[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=requestargindent)headers[ \t]*=[ \t]*\{(?:(?!\}).){0,1000}?["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*)(?:(?!^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)(?!^[ \t]*\#)[^\n]*\n){0,30}?^(?P=bodyindent)\)[ \t]*(?:\#[^\n]*)?$)\n(?!(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)if[ \t]+[^\n:]+:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)[^\n]*\n^(?P=bodyindent)else[ \t]*:[^\n]*\n^(?P=allkillindent)(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)(?!(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)if[ \t]+[^\n:]+:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return|raise)\b[^\n]*\n^(?P=bodyindent)else[ \t]*:[^\n]*\n^(?P=deadbranchindent)(?:return|raise)\b[^\n]*\n(?:(?:^(?P=bodyindent)[ \t]+\#[^\n]*\n)|(?:^[ \t]*\n)){0,8}^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers\.clear[ \t]*\([ \t]*\)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,1500}?^(?P=authremoveindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)(?!(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b).){0,3000}?^(?P(?P=bodyindent)[ \t]+)(?P=req)[ \t]*=[ \t]*urllib\.request\.Request[ \t]*\((?:(?!(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b)[^\n]*|(?:url[ \t]*=[ \t]*)?(?P=endpoint)\b(?![^\n\#]*["\x27]Authorization["\x27][^\n\#]*Bearer)[^\n]*)\)[ \t]*(?:\#[^\n]*)?\n(?:(?!^(?P=bodyindent)\S).){0,1500}?(?:^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=reqreplaceindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[^\n]*\n(?:(?!^(?P=bodyindent)\S).){0,800}?^(?P=reqreplaceindent)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=nestedopener)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b))(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers\.clear[ \t]*\([ \t]*\)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+)).){0,3000}?(?:^(?P=bodyindent)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=defindent)(?:async[ \t]+)?def\b|^(?P=defindent)class\b)(?!^(?P=bodyindent)(?:return|raise)\b)(?!^(?P=bodyindent)(?P=req)[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?P=req)\b))(?!^(?P=bodyindent)(?:(?P=req)\.remove_header[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*\)|(?P=req)\.headers\.pop[ \t]*\([ \t]*["\x27]Authorization["\x27][^\n]*\)|del[ \t]+(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\]|(?P=req)\.(?:add_header|add_unredirected_header)[ \t]*\([ \t]*["\x27]Authorization["\x27][ \t]*,[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^,\n\)]*|(?P=req)\.headers[ \t]*\[[ \t]*["\x27]Authorization["\x27][ \t]*\][ \t]*=[ \t]*(?!(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers[ \t]*=[ \t]*(?!\{[^\n#}]*["\x27]Authorization["\x27][ \t]*:[ \t]*(?:f?["\x27]Bearer[ \t]+\{?[A-Za-z_][A-Za-z0-9_]*\}?[^"\x27\n]*["\x27]|["\x27]Bearer[ \t]+["\x27][ \t]*\+[ \t]*[A-Za-z_][A-Za-z0-9_]*))[^#\n]+|(?P=req)\.headers\.clear[ \t]*\([ \t]*\)|(?P=req)\.full_url[ \t]*=[ \t]*(?![^"\x27\n#]*\b(?:(?P=endpoint)|(?P=url))\b)[^#\n]+)).){0,1000}?^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener1)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*urllib\.request\.build_opener[ \t]*\([ \t]*SafeRedirectHandler[ \t]*\([ \t]*\)[ \t]*\)[ \t]*[^\n]*\n(?:(?!^(?P=bodyindent)(?:except|finally)\b).){0,1200}?^[ \t]+(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?(?P=opener2)\.open[ \t]*\([ \t]*(?:\n[ \t]+)?(?:fullurl[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)[ \t]*with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)(?:return[ \t]+|[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b|^(?P=bodyindent)try[ \t]*:[^\n]*\n^(?P(?P=bodyindent)[ \t]+)with[ \t]+urllib\.request\.urlopen[ \t]*\([ \t]*(?:\n[ \t]+)?(?:url[ \t]*=[ \t]*)?(?P=req)\b)' message: | A bearer-authenticated urllib request is sent after a separate URL-safety preflight. The network client can resolve the hostname again after validation, From 0a752c091489efd4dc7373230f1e242313e7cca6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:14:34 +0900 Subject: [PATCH 105/105] test(sast): lock one-branch sanitizer reachability --- ..._toctou_one_branch_sanitizer_regression.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/test_bearer_dns_toctou_one_branch_sanitizer_regression.py diff --git a/tests/test_bearer_dns_toctou_one_branch_sanitizer_regression.py b/tests/test_bearer_dns_toctou_one_branch_sanitizer_regression.py new file mode 100644 index 00000000..160875ab --- /dev/null +++ b/tests/test_bearer_dns_toctou_one_branch_sanitizer_regression.py @@ -0,0 +1,58 @@ +"""Regression coverage for conditional Authorization sanitization before an outer sink.""" + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + +_PRIMARY = "python-bearer-preflight-dns-toctou" +_FAMILY = { + _PRIMARY, + "python-bearer-preflight-dns-toctou-header-mutation", + "python-bearer-preflight-dns-toctou-multiline-constructor", + "python-bearer-preflight-dns-toctou-multiline-header-mutation", + "python-bearer-preflight-dns-toctou-dynamic-bearer-replacement", + "python-bearer-preflight-dns-toctou-unredirected-header-persistence", +} + + +def _scan_source(tmp_path: Path, source: str): + source_file = tmp_path / "push.py" + source_file.write_text(source, encoding="utf-8") + return [ + finding + for finding in _scan_file(source_file, tmp_path) + if finding["rule_id"] in _FAMILY + ] + + +def _direct_bearer_prefix() -> str: + return """\ +def deliver(url, api_key, rotate=False): + if not _is_safe_url(url): + return None + endpoint = url.rstrip("/") + "/api/v1/scans" + req = urllib.request.Request( + endpoint, + headers={"Authorization": f"Bearer {api_key}"}, + ) +""" + + +def test_one_branch_removal_does_not_sanitize_outer_dispatch(tmp_path): + source = _direct_bearer_prefix() + """\ + if rotate: + req.remove_header("Authorization") + return urllib.request.urlopen(req, timeout=5) +""" + findings = _scan_source(tmp_path, source) + assert [finding["rule_id"] for finding in findings] == [_PRIMARY] + + +def test_same_branch_removal_sanitizes_same_branch_dispatch(tmp_path): + source = _direct_bearer_prefix() + """\ + if rotate: + req.remove_header("Authorization") + return urllib.request.urlopen(req, timeout=5) + return None +""" + assert not _scan_source(tmp_path, source)