Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,8 @@
**Vulnerability:** Server-Side Request Forgery (SSRF) bypass due to `_is_safe_url` only checking `is_loopback` and `is_private`. This fails to correctly evaluate mapped IPv4 addresses disguised as IPv6 (e.g. `[::ffff:127.0.0.1]`) and misses restricted IP designations like `is_reserved` or non `is_global` IPs, allowing SSRF to `0.0.0.0` or `255.255.255.255`.
**Learning:** Python's `ipaddress` objects for mapped IPv6 don't inherit properties of their IPv4 wrapped content directly. Using `is_loopback` without checking `.ipv4_mapped` leaves blind spots.
**Prevention:** Always extract `getattr(ip, 'ipv4_mapped', None)` before evaluation, and combine checks spanning `is_reserved`, `not is_global`, `is_multicast`, `is_unspecified`, `is_private`, and `is_loopback` to fully protect endpoints.

## 2026-07-20 - Block HTTP Redirects in URL fetchers to prevent SSRF bypass
**Vulnerability:** Server-Side Request Forgery (SSRF) bypass through HTTP redirects. When a user provides an external URL that passes `_is_safe_url` validation, `urllib.request.urlopen` automatically follows redirects. If the external server redirects to an internal or restricted IP (e.g., `http://127.0.0.1`), the request succeeds, bypassing the initial safety checks.
**Learning:** `urllib.request.urlopen` follows HTTP redirects by default. Validating the initial URL is insufficient if the client subsequently fetches an unvalidated redirect target.
**Prevention:** Explicitly disable HTTP redirects when fetching user-provided URLs by passing a custom `urllib.request.HTTPRedirectHandler` (that prevents redirects) to `urllib.request.build_opener()`.
19 changes: 15 additions & 4 deletions appguardrail_core/controlplane.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
import secrets
import sqlite3
from datetime import datetime, timezone
from importlib import resources # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2

try:
from importlib import resources # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2
except ImportError:
import importlib_resources as resources
from typing import Any, Iterable
from urllib.parse import parse_qs, urlparse

Expand Down Expand Up @@ -216,11 +220,13 @@ def _slack_blocks(

def _is_safe_url(url: str) -> bool:
import ipaddress
import urllib.parse
import socket
import urllib.parse

try:
parsed = urllib.parse.urlparse(url) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
parsed = urllib.parse.urlparse(
url
) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
except ValueError:
return False

Expand Down Expand Up @@ -286,6 +292,10 @@ def _send_alert(
import urllib.error
import urllib.request

class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None

if not _is_safe_url(url):
return False

Expand All @@ -301,7 +311,8 @@ def _send_alert(
method="POST",
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
opener = urllib.request.build_opener(_NoRedirectHandler)
opener.open( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
req, timeout=10
) # noqa: S310 - Safe URL scheme validated
return True
Expand Down
140 changes: 93 additions & 47 deletions scanner/cli/appguardrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,28 +60,20 @@
from appguardrail_core.config import load_config
from appguardrail_core.external import build_external_scan_plan
from appguardrail_core.findings import NON_BLOCKING_CONTEXTS
from appguardrail_core.findings import is_deploy_blocking as core_is_deploy_blocking
from appguardrail_core.findings import \
is_deploy_blocking as core_is_deploy_blocking
from appguardrail_core.findings import normalize_findings
from appguardrail_core.language import (
LANGUAGE_EXTENSIONS,
detect_language_axes,
detect_stack_profile,
)
from appguardrail_core.org_bundle import (
OrgBundleError,
annotate_missing_pr_repositories,
gh_error_message,
gh_pr_list,
gh_repo_list,
)
from appguardrail_core.language import (LANGUAGE_EXTENSIONS,
detect_language_axes,
detect_stack_profile)
from appguardrail_core.org_bundle import (OrgBundleError,
annotate_missing_pr_repositories,
gh_error_message, gh_pr_list,
gh_repo_list)
from appguardrail_core.org_bundle import load_json as load_org_json
from appguardrail_core.org_bundle import render_org_evidence, write_bundle
from appguardrail_core.reports import (
REPORT_TYPE_LABELS,
ReportContext,
render_report,
supported_report_types,
)
from appguardrail_core.reports import (REPORT_TYPE_LABELS, ReportContext,
render_report, supported_report_types)
from appguardrail_core.rules import build_rule_metadata

__version__ = "0.1.1"
Expand All @@ -100,10 +92,7 @@ def _format_msg(msg: str) -> str:
def _console_print(*values, **kwargs) -> None:
"""Print CLI values after applying accessibility formatting to strings."""
_ORIGINAL_PRINT(
*(
_format_msg(value) if isinstance(value, str) else value
for value in values
),
*(_format_msg(value) if isinstance(value, str) else value for value in values),
**kwargs,
)

Expand Down Expand Up @@ -1397,7 +1386,9 @@ def cmd_scan(args):
_console_print(f"\nπŸ” AppGuardrail scanning: {scan_path}\n")

if run_codegraph:
_console_print("🧭 CodeGraph enabled: initializing or syncing structural index\n")
_console_print(
"🧭 CodeGraph enabled: initializing or syncing structural index\n"
)
try:
status = _run_codegraph_index(scan_path)
except RuntimeError as exc:
Expand Down Expand Up @@ -1435,9 +1426,13 @@ def cmd_scan(args):
if profile.frameworks:
_console_print(f" Framework signals: {', '.join(profile.frameworks)}")
if profile.external_tools:
_console_print(f" Optional external engines: {', '.join(profile.external_tools)}")
_console_print(
f" Optional external engines: {', '.join(profile.external_tools)}"
)
if profile.zap_recommended:
_console_print(" ZAP baseline: provide --zap-baseline <url> for authorized DAST")
_console_print(
" ZAP baseline: provide --zap-baseline <url> for authorized DAST"
)
_console_print()

external_plan = build_external_scan_plan(
Expand Down Expand Up @@ -1610,11 +1605,13 @@ def _write_findings_json(findings, output_path: Path):

def _is_safe_url(url: str) -> bool:
import ipaddress
import urllib.parse
import socket
import urllib.parse

try:
parsed = urllib.parse.urlparse(url) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
parsed = urllib.parse.urlparse(
url
) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
except ValueError:
return False

Expand Down Expand Up @@ -1688,6 +1685,11 @@ def _push_findings(url, findings):
"commit": os.environ.get("GITHUB_SHA"),
}
endpoint = url.rstrip("/") + "/api/v1/scans"

class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None

req = urllib.request.Request( # noqa: S310 - Safe URL scheme validated
endpoint,
data=json.dumps(payload).encode("utf-8"),
Expand All @@ -1698,7 +1700,8 @@ def _push_findings(url, findings):
},
)
try:
with urllib.request.urlopen( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
opener = urllib.request.build_opener(_NoRedirectHandler)
with opener.open( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
req, timeout=15
) as resp: # noqa: S310 - Safe URL scheme validated
body = json.loads(resp.read() or b"{}")
Expand Down Expand Up @@ -1799,7 +1802,9 @@ def cmd_fix(args):
f"\nπŸ”§ {total_fixes} safe fix{fix_s} available in {changed_files} file{file_s}. "
"Re-run with --apply to write them."
)
_console_print(" Other findings need review β€” see 'appguardrail report fix-pack'.")
_console_print(
" Other findings need review β€” see 'appguardrail report fix-pack'."
)
return 0


Expand Down Expand Up @@ -1837,7 +1842,9 @@ def cmd_report(args):
"""Generate markdown reports from normalized AppGuardrail findings JSON."""
report_type = getattr(args, "report_type", None)
if report_type not in supported_report_types():
_console_print(f"❌ Error: Unsupported report type: {report_type}", file=sys.stderr)
_console_print(
f"❌ Error: Unsupported report type: {report_type}", file=sys.stderr
)
_console_print(
"πŸ’‘ Hint: Supported report types are: "
+ ", ".join(supported_report_types()),
Expand Down Expand Up @@ -2096,7 +2103,9 @@ def _path_matches_glob(path: str, pattern: str) -> bool:


@functools.lru_cache(maxsize=2048)
def _path_allowed_by_rule_cached(path: str, include_paths: tuple, exclude_paths: tuple) -> bool:
def _path_allowed_by_rule_cached(
path: str, include_paths: tuple, exclude_paths: tuple
) -> bool:
"""Return whether a path passes optional YAML include/exclude filters (cached)."""
if include_paths and not any(
_path_matches_glob(path, glob) for glob in include_paths
Expand All @@ -2106,9 +2115,14 @@ def _path_allowed_by_rule_cached(path: str, include_paths: tuple, exclude_paths:
return False
return True


def _path_allowed_by_rule(path: str, include_paths, exclude_paths) -> bool:
"""Return whether a path passes optional YAML include/exclude filters."""
return _path_allowed_by_rule_cached(path, tuple(include_paths) if include_paths else (), tuple(exclude_paths) if exclude_paths else ())
return _path_allowed_by_rule_cached(
path,
tuple(include_paths) if include_paths else (),
tuple(exclude_paths) if exclude_paths else (),
)


def _collect_files(base_path: Path):
Expand Down Expand Up @@ -3010,18 +3024,30 @@ def _print_scan_results(findings, files_scanned):
_console_print("\n⚠️ No files were scanned. Are you in the right directory?")
elif counts["CRITICAL"] > 0:
issue_word = "issue" if counts["CRITICAL"] == 1 else "issues"
_console_print(_format_msg(f"\n❌ Critical {issue_word} found. Fix before deploying."))
_console_print(
_format_msg(f"\n❌ Critical {issue_word} found. Fix before deploying.")
)
elif counts["HIGH"] > 0:
issue_word = "issue" if counts["HIGH"] == 1 else "issues"
_console_print(_format_msg(f"\n⚠️ High-severity {issue_word} found. Review before deploying."))
_console_print(
_format_msg(
f"\n⚠️ High-severity {issue_word} found. Review before deploying."
)
)
elif not findings:
_console_print(_format_msg("\nβœ… No issues found in this scan."))
else:
_console_print(_format_msg("\nβœ… No deploy-blocking critical or high issues found."))
_console_print(
_format_msg("\nβœ… No deploy-blocking critical or high issues found.")
)

if findings:
these_word = "this issue" if len(findings) == 1 else "these issues"
_console_print(_format_msg(f"\nπŸ’‘ Run 'appguardrail review' to get an AI prompt for fixing {these_word}."))
_console_print(
_format_msg(
f"\nπŸ’‘ Run 'appguardrail review' to get an AI prompt for fixing {these_word}."
)
)
_console_print()


Expand Down Expand Up @@ -3051,8 +3077,12 @@ def cmd_review(args):
_console_print("═" * 60 + "\n")
_console_print("πŸ’‘ Tips:")
_console_print(" - Paste this into Claude Code, Cursor, or any AI assistant")
_console_print(" - Include relevant files as context (API routes, DB schema, etc.)")
_console_print(" - Run 'appguardrail scan .' first to identify specific files to review")
_console_print(
" - Include relevant files as context (API routes, DB schema, etc.)"
)
_console_print(
" - Run 'appguardrail scan .' first to identify specific files to review"
)
_console_print()


Expand Down Expand Up @@ -3220,15 +3250,19 @@ def cmd_serve(args):
key_path = _api_key_output_path(args, db)
if key_path.exists():
conn.close()
_console_print(f"❌ API key file already exists: {key_path}", file=sys.stderr)
_console_print(
f"❌ API key file already exists: {key_path}", file=sys.stderr
)
_console_print("πŸ’‘ Pass --api-key-file with a new path.", file=sys.stderr)
return 1
oid, key = cp.create_org(conn, create)
conn.close()
try:
_persist_api_key(key_path, key)
except FileExistsError:
_console_print(f"❌ API key file already exists: {key_path}", file=sys.stderr)
_console_print(
f"❌ API key file already exists: {key_path}", file=sys.stderr
)
_console_print("πŸ’‘ Pass --api-key-file with a new path.", file=sys.stderr)
return 1
_console_print(f"βœ… Created org '{create}' (id {oid}).")
Expand All @@ -3238,15 +3272,19 @@ def cmd_serve(args):
key_path = _api_key_output_path(args, db)
if key_path.exists():
conn.close()
_console_print(f"❌ API key file already exists: {key_path}", file=sys.stderr)
_console_print(
f"❌ API key file already exists: {key_path}", file=sys.stderr
)
_console_print("πŸ’‘ Pass --api-key-file with a new path.", file=sys.stderr)
return 1
_oid, key = cp.create_org(conn, "default")
try:
_persist_api_key(key_path, key)
except FileExistsError:
conn.close()
_console_print(f"❌ API key file already exists: {key_path}", file=sys.stderr)
_console_print(
f"❌ API key file already exists: {key_path}", file=sys.stderr
)
_console_print("πŸ’‘ Pass --api-key-file with a new path.", file=sys.stderr)
return 1
_console_print("ℹ️ No orgs yet β€” created 'default'.")
Expand Down Expand Up @@ -3323,7 +3361,9 @@ def cmd_dashboard(args):

index = dashboard_index_path()
if not index.is_file():
_console_print(f"❌ Error: Dashboard assets not found at {index}", file=sys.stderr)
_console_print(
f"❌ Error: Dashboard assets not found at {index}", file=sys.stderr
)
_console_print(
"πŸ’‘ Hint: Check if the path is correct or if you are in the right directory.",
file=sys.stderr,
Expand All @@ -3342,7 +3382,9 @@ def cmd_dashboard(args):
" Generate one with: "
"appguardrail scan --findings-json reports/findings.json ."
)
_console_print(" The dashboard opens with instructions β€” reload after generating.\n")
_console_print(
" The dashboard opens with instructions β€” reload after generating.\n"
)

tokens_css = b""
tokens_file = dashboard_tokens_path()
Expand All @@ -3364,8 +3406,12 @@ def cmd_dashboard(args):
host, port, index.read_bytes(), findings_path, tokens_css
)
except OSError as exc:
_console_print(f"❌ Cannot start dashboard on {host}:{port} ({exc}).", file=sys.stderr)
_console_print("πŸ’‘ Pass a free port with --port, e.g. --port 8899.", file=sys.stderr)
_console_print(
f"❌ Cannot start dashboard on {host}:{port} ({exc}).", file=sys.stderr
)
_console_print(
"πŸ’‘ Pass a free port with --port, e.g. --port 8899.", file=sys.stderr
)
return 1

actual_port = server.server_address[1]
Expand Down
15 changes: 9 additions & 6 deletions tests/test_controlplane.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,16 +352,19 @@ def test_slack_blocks_caps_and_escapes():
def test_send_alert_slack_vs_generic(monkeypatch):
posted = {}

def _fake_urlopen(req, timeout=None):
posted["url"] = req.full_url
posted["body"] = json.loads(req.data.decode())
def _fake_open(self, req, data=None, timeout=None):
posted["url"] = req.full_url if hasattr(req, "full_url") else req
posted["body"] = json.loads(req.data.decode()) if hasattr(req, "data") else None

class _R: # minimal stand-in, urlopen result is ignored
pass
class _R:
def close(self): pass
@property
def status(self): return 200
def read(self): return b"{}"

return _R()

monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen)
monkeypatch.setattr(urllib.request.OpenerDirector, "open", _fake_open)
generic = {
"event": "drift.new_blocking",
"org_id": 3,
Expand Down
Loading