Repository navigation
security(scanner): detect stored SSRF webhook persistence #910
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
seonghobae
merged 28 commits into
develop
from
sentinel/fix-webhook-ssrf-17689544248715160075
Aug 12, 2026
Merged
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
d157667
🛡️ Sentinel: [CRITICAL] Fix stored SSRF in webhook URL endpoint
seonghobae 5eddd98
test(security): reject non-string webhook URLs
seonghobae 7bc59a1
test(security): exercise malformed webhook bodies at API boundary
seonghobae fbc77cb
🛡️ Sentinel: [CRITICAL] Fix stored SSRF and Uncaught Exception in web…
seonghobae e9d4946
test(scanner): reproduce stored SSRF detection gap
seonghobae 057df0e
security(scanner): detect direct stored webhook SSRF
seonghobae 3d6f57b
test(controlplane): pin webhook input validation boundary
seonghobae 6d55440
fix(controlplane): reject malformed webhook payloads
seonghobae c9752fa
test(controlplane): prove invalid webhook input preserves state
seonghobae 1689ba7
test(scanner): expose stored SSRF variable-flow blind spot
seonghobae 30af47d
fix(scanner): detect unvalidated stored SSRF variable flow
seonghobae 35578cd
test(scanner): expose SSRF finding metadata misclassification
seonghobae e051776
fix(scanner): classify CWE-918 findings as SSRF
seonghobae e65cda8
test(scanner): expose stored SSRF rule evasion paths
seonghobae 2cd9a8f
fix(scanner): close stored SSRF rule evasion paths
seonghobae 2ce25b2
test(scanner): expose non-enforcing SSRF guard bypass
seonghobae 14240fa
test(scanner): pin fail-closed SSRF guard semantics
seonghobae 48a6da5
fix(scanner): require enforcing SSRF validation guards
seonghobae 40b5c63
docs(tests): document stored SSRF regression contracts
seonghobae 40c521e
test(scanner): expose equivalent stored SSRF accessors
seonghobae 999095e
fix(scanner): detect equivalent stored SSRF accessors
seonghobae 5ba9131
docs(tests): document webhook validation helpers
seonghobae 84a2708
security(scanner): preserve stored SSRF detection after endpoint fix
seonghobae c75987d
perf(scanner): prefilter stored SSRF rule by sink literal
seonghobae b5c95e3
fix(scanner): evaluate stored SSRF validation per sink
seonghobae 2beb859
fix(scanner): accept none-aware fail-closed SSRF guard
seonghobae 1013819
🎨 Palette: 개선된 파일 업로드 버튼 UX 적용 (#925)
seonghobae 6c6a830
chore: restack stored SSRF scanner on current develop
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| rules: | ||
| - id: python-stored-ssrf-webhook-url | ||
| patterns: | ||
| - pattern-regex: '(?is)(?:\bset_webhook\s*\(\s*[^,\n]+,\s*[^,\n]+,\s*(?:\([^\)\n]*\)|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)(?:\.get\s*\(\s*["\x27]url["\x27]\s*\)|\[\s*["\x27]url["\x27]\s*\])|\b(?P<webhook_url_var>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:\([^\)\n]*\)|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)(?:\.get\s*\(\s*["\x27]url["\x27]\s*\)|\[\s*["\x27]url["\x27]\s*\])(?!(?:(?!\bset_webhook\b).){0,800}(?m:^(?P<reject_indent>[ \t]*)if\b(?:(?!:).){0,600}\bnot\s+_is_safe_url\s*\(\s*(?P=webhook_url_var)\s*\)(?:(?!:).){0,100}:\s*(?:#[^\n]*)?\n(?:(?P=reject_indent)[ \t]+[^\n]*\n){0,20}?(?P=reject_indent)[ \t]+\b(?:return|raise)\b))(?!(?:(?!\bset_webhook\b).){0,800}(?m:^(?P<guard_indent>[ \t]*)if\s+(?:(?!\bnot\b)[^\n:]){0,400}_is_safe_url\s*\(\s*(?P=webhook_url_var)\s*\)[^\n:]{0,100}:\s*(?:#[^\n]*)?\n(?:(?P=guard_indent)[ \t]+[^\n]*\n){0,20}?(?P=guard_indent)[ \t]+\bset_webhook\s*\(\s*[^,\n]+,\s*[^,\n]+,\s*(?P=webhook_url_var)\s*\)))(?:(?!\bset_webhook\b).){0,800}?\bset_webhook\s*\(\s*[^,\n]+,\s*[^,\n]+,\s*(?P=webhook_url_var)\s*\))' | ||
| message: | | ||
| A webhook URL is persisted directly from request data without an explicit | ||
| validation boundary. Validate the URL with a fail-closed SSRF policy before | ||
| storing it, and revalidate or pin the destination before outbound delivery. | ||
| [CWE-918 - Server-Side Request Forgery] | ||
| severity: HIGH | ||
| languages: [python] | ||
| cwe: [CWE-918] | ||
| owasp: [A10:2021] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| """Regression tests for equivalent stored-SSRF request accessors.""" | ||
|
|
||
| import pytest | ||
|
|
||
| from scanner.cli.appguardrail import SCAN_RULES, _scan_file | ||
|
|
||
| _RULE_ID = "python-stored-ssrf-webhook-url" | ||
|
|
||
|
|
||
| def _rule(): | ||
| """Return the packaged stored-SSRF rule under test.""" | ||
| matches = [rule for rule in SCAN_RULES if rule["id"] == _RULE_ID] | ||
| assert len(matches) == 1 | ||
| return matches[0] | ||
|
|
||
|
|
||
| def _source(accessor, *, direct=False, validated=False): | ||
| """Build a direct or one-hop request URL persistence flow.""" | ||
| sink = "set_" + "webhook" | ||
| if direct: | ||
| return "\n".join( | ||
| [ | ||
| "def update_webhook(conn, org, body):", | ||
| f" {sink}(conn, org, {accessor})", | ||
| "", | ||
| ] | ||
| ) | ||
|
|
||
| lines = [ | ||
| "def update_webhook(conn, org, body):", | ||
| f" target = {accessor}", | ||
| ] | ||
| if validated: | ||
| lines.extend( | ||
| [ | ||
| " if not _is_safe_url(target):", | ||
| " return", | ||
| ] | ||
| ) | ||
| lines.extend([f" {sink}(conn, org, target)", ""]) | ||
| return "\n".join(lines) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "source", | ||
| [ | ||
| _source('body["url"]', direct=True), | ||
| _source('request.json["url"]'), | ||
| _source('request.json.get("url")'), | ||
| ], | ||
| ) | ||
| def test_packaged_rule_matches_equivalent_request_url_accessors(source): | ||
| """Detect direct, subscript, and attribute-based URL sources.""" | ||
| assert _rule()["pattern"].search(source) | ||
|
|
||
|
|
||
| def test_scan_file_emits_finding_for_subscript_variable_flow(tmp_path): | ||
| """Emit the stored-SSRF finding for a subscript one-hop flow.""" | ||
| source_file = tmp_path / "webhook.py" | ||
| source_file.write_text(_source('body["url"]'), encoding="utf-8") | ||
|
|
||
| findings = [ | ||
| finding | ||
| for finding in _scan_file(source_file, tmp_path) | ||
| if finding["rule_id"] == _RULE_ID | ||
| ] | ||
|
|
||
| assert len(findings) == 1 | ||
| assert findings[0]["line"] == 2 | ||
| assert findings[0]["category"] == "ssrf" | ||
|
|
||
|
|
||
| def test_packaged_rule_ignores_validated_subscript_flow(): | ||
| """Do not flag a subscript source protected by a fail-closed guard.""" | ||
| assert not _rule()["pattern"].search( | ||
| _source('body["url"]', validated=True) | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| """Regression tests for stored SSRF detection in the packaged rule engine.""" | ||
|
|
||
| from scanner.cli.appguardrail import SCAN_RULES, _scan_file | ||
|
|
||
| _RULE_ID = "python-stored-ssrf-webhook-url" | ||
|
|
||
|
|
||
| def _rule(): | ||
| """Return the single packaged stored-SSRF rule under test.""" | ||
| 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 _vulnerable_source(): | ||
| """Build the original direct request-to-persistence vulnerability.""" | ||
| sink = "set_" + "webhook" | ||
| return "\n".join( | ||
| [ | ||
| "def update_webhook(conn, org, body):", | ||
| f' {sink}(conn, org, (body or {{}}).get("url"))', | ||
| "", | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def _unvalidated_variable_source(variable="webhook_url"): | ||
| """Build an unvalidated local-variable flow into webhook persistence.""" | ||
| sink = "set_" + "webhook" | ||
| return "\n".join( | ||
| [ | ||
| "def update_webhook(conn, org, body):", | ||
| f' {variable} = (body or {{}}).get("url")', | ||
| f" {sink}(conn, org, {variable})", | ||
| "", | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def _ignored_validation_result_source(): | ||
| """Build a flow that calls the validator but discards its result.""" | ||
| sink = "set_" + "webhook" | ||
| return "\n".join( | ||
| [ | ||
| "def update_webhook(conn, org, body):", | ||
| ' target = (body or {}).get("url")', | ||
| " _is_safe_url(target)", | ||
| f" {sink}(conn, org, target)", | ||
| "", | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def _non_enforcing_guard_source(): | ||
| """Build a guard that logs invalid input without blocking persistence.""" | ||
| sink = "set_" + "webhook" | ||
| return "\n".join( | ||
| [ | ||
| "def update_webhook(conn, org, body):", | ||
| ' target = (body or {}).get("url")', | ||
| " if not _is_safe_url(target):", | ||
| ' log.warning("unsafe webhook url")', | ||
| f" {sink}(conn, org, target)", | ||
| "", | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def _non_enforcing_guard_with_unrelated_return_source(): | ||
| """Build a non-enforcing guard followed by an unrelated early return.""" | ||
| sink = "set_" + "webhook" | ||
| return "\n".join( | ||
| [ | ||
| "def update_webhook(conn, org, body, disabled):", | ||
| ' target = (body or {}).get("url")', | ||
| " if not _is_safe_url(target):", | ||
| ' log.warning("unsafe webhook url")', | ||
| " if disabled:", | ||
| " return", | ||
| f" {sink}(conn, org, target)", | ||
| "", | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def _positive_guard_source(): | ||
| """Build a safe flow whose persistence sink is inside a positive guard.""" | ||
| sink = "set_" + "webhook" | ||
| return "\n".join( | ||
| [ | ||
| "def update_webhook(conn, org, body):", | ||
| ' target = (body or {}).get("url")', | ||
| " if _is_safe_url(target):", | ||
| f" {sink}(conn, org, target)", | ||
| "", | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def _safe_source(): | ||
| """Build a safe flow that raises before persisting invalid input.""" | ||
| sink = "set_" + "webhook" | ||
| return "\n".join( | ||
| [ | ||
| "def update_webhook(conn, org, body):", | ||
| ' webhook_url = (body or {}).get("url")', | ||
| " if webhook_url and not _is_safe_url(webhook_url):", | ||
| " raise ValueError(\"unsafe webhook url\")", | ||
| f" {sink}(conn, org, webhook_url)", | ||
| "", | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def _production_guard_source(): | ||
| """Build the multiline fail-closed guard used by the control plane.""" | ||
| sink = "set_" + "webhook" | ||
| return "\n".join( | ||
| [ | ||
| "def update_webhook(conn, org, body):", | ||
| ' webhook_url = body.get("url")', | ||
| ' if webhook_url not in (None, "") and (', | ||
| " not isinstance(webhook_url, str)", | ||
| " or not _is_safe_url(webhook_url)", | ||
| " ):", | ||
| ' return {"error": "unsafe webhook url"}', | ||
| f" {sink}(conn, org, webhook_url)", | ||
| "", | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def _scan_rule_findings(tmp_path, source): | ||
| """Run the real file scanner and return only stored-SSRF findings.""" | ||
| source_file = tmp_path / "webhook.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_packaged_rule_matches_direct_request_url_persistence(): | ||
| """Detect direct request URL persistence with HIGH severity.""" | ||
| rule = _rule() | ||
| assert rule["severity"] == "HIGH" | ||
| assert rule["pattern"].search(_vulnerable_source()) | ||
|
|
||
|
|
||
| def test_packaged_rule_matches_unvalidated_variable_persistence(): | ||
| """Detect source-to-sink persistence through a local variable.""" | ||
| assert _rule()["pattern"].search(_unvalidated_variable_source()) | ||
|
|
||
|
|
||
| def test_packaged_rule_does_not_depend_on_url_variable_name(): | ||
| """Detect the flow even when the variable name omits the word URL.""" | ||
| assert _rule()["pattern"].search(_unvalidated_variable_source("target")) | ||
|
|
||
|
|
||
| def test_packaged_rule_does_not_treat_ignored_validator_result_as_safe(): | ||
| """Detect a validator call whose boolean result is discarded.""" | ||
| assert _rule()["pattern"].search(_ignored_validation_result_source()) | ||
|
|
||
|
|
||
| def test_packaged_rule_matches_non_enforcing_validation_guard(): | ||
| """Detect a validation branch that logs but does not terminate.""" | ||
| assert _rule()["pattern"].search(_non_enforcing_guard_source()) | ||
|
|
||
|
|
||
| def test_packaged_rule_matches_non_enforcing_guard_with_unrelated_return(): | ||
| """Ignore unrelated returns when deciding whether validation enforces.""" | ||
| assert _rule()["pattern"].search( | ||
| _non_enforcing_guard_with_unrelated_return_source() | ||
| ) | ||
|
|
||
|
|
||
| def test_packaged_rule_ignores_positive_guarded_persistence(): | ||
| """Do not flag a sink that is reachable only after positive validation.""" | ||
| assert not _rule()["pattern"].search(_positive_guard_source()) | ||
|
|
||
|
|
||
| def test_packaged_rule_ignores_fail_closed_guarded_persistence(): | ||
| """Do not flag a flow that raises on invalid input before persistence.""" | ||
| assert not _rule()["pattern"].search(_safe_source()) | ||
|
|
||
|
|
||
| def test_packaged_rule_ignores_production_fail_closed_guard(): | ||
| """Do not self-flag the control plane's multiline rejection guard.""" | ||
| assert not _rule()["pattern"].search(_production_guard_source()) | ||
|
|
||
|
|
||
| def test_scan_file_emits_stored_ssrf_finding(tmp_path): | ||
| """Emit a normalized SSRF finding through the production file scanner.""" | ||
| matches = _scan_rule_findings(tmp_path, _vulnerable_source()) | ||
|
|
||
| assert len(matches) == 1 | ||
| finding = matches[0] | ||
| assert finding["severity"] == "HIGH" | ||
| assert finding["source"] == "appguardrail-rule" | ||
| assert finding["file"] == "webhook.py" | ||
| assert finding["line"] == 2 | ||
| assert finding["category"] == "ssrf" | ||
| assert finding["cwe"] == ("CWE-918 - Server-Side Request Forgery",) | ||
| assert finding["owasp"] == ("OWASP A10:2021 - Server-Side Request Forgery",) | ||
| assert "destination" in finding["remediation"].lower() | ||
|
|
||
|
|
||
| def test_scan_file_emits_stored_ssrf_finding_for_variable_flow(tmp_path): | ||
| """Emit a finding for an unvalidated local-variable persistence flow.""" | ||
| matches = _scan_rule_findings(tmp_path, _unvalidated_variable_source()) | ||
|
|
||
| assert len(matches) == 1 | ||
| assert matches[0]["line"] == 2 | ||
|
|
||
|
|
||
| def test_scan_file_emits_finding_when_validator_result_is_ignored(tmp_path): | ||
| """Emit a finding when code ignores the validator's return value.""" | ||
| matches = _scan_rule_findings(tmp_path, _ignored_validation_result_source()) | ||
|
|
||
| assert len(matches) == 1 | ||
| assert matches[0]["line"] == 2 | ||
|
|
||
|
|
||
| def test_scan_file_emits_finding_for_non_enforcing_guard(tmp_path): | ||
| """Emit a finding when an invalid branch fails to stop persistence.""" | ||
| matches = _scan_rule_findings(tmp_path, _non_enforcing_guard_source()) | ||
|
|
||
| assert len(matches) == 1 | ||
| assert matches[0]["line"] == 2 | ||
|
|
||
|
|
||
| def test_scan_file_does_not_flag_validated_path(tmp_path): | ||
| """Suppress the finding for a verified fail-closed persistence path.""" | ||
| assert not _scan_rule_findings(tmp_path, _safe_source()) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.