Skip to content
Open
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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@
**Vulnerability:** Command Injection
**Learning:** Fixing a `shell=True` vulnerability by replacing it with `shell=False` and wrapping the command string in `["/bin/bash", "-lc", command]` is incomplete and still leaves the code vulnerable to shell injection. It acts as security theater, as it misleads linters while executing untrusted input via the bash wrapper. The vulnerability was still present in `sandboxed_web_e2e.py`.
**Prevention:** Remove `/bin/bash` wrapper from `subprocess` calls in CI scripts. Always use `shlex.split(command)` to safely parse strings into a list of arguments and pass the list directly to `subprocess.Popen` or `subprocess.run`.
## 2026-08-06 - Prevent SSRF via IP Address Validation
**Vulnerability:** Server-Side Request Forgery (SSRF)
**Learning:** URL scheme validation (checking for `http://` or `https://`) and disabling HTTP redirects are not sufficient to prevent all forms of Server-Side Request Forgery. If an attacker can provide an arbitrary HTTP/HTTPS URL, they can point it to a private or loopback IP address (e.g. `http://127.0.0.1` or `http://10.0.0.1`), allowing them to scan or interact with internal services that the host environment can reach.
**Prevention:** In addition to validating URL schemes, always resolve the hostname to its IP address and use the `ipaddress` module to verify that the IP is not private (`ip.is_private`) or a loopback (`ip.is_loopback`) address before making the request.
17 changes: 17 additions & 0 deletions scripts/ci/sandboxed_web_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
import subprocess
import sys
import tempfile
import ipaddress
import socket
import time
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Sequence
from dataclasses import dataclass
Expand Down Expand Up @@ -121,6 +124,20 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool:
return True
if not (url.startswith("http://") or url.startswith("https://")):
raise ValueError(f"URL must start with http:// or https://, got: {url}")

parsed = urllib.parse.urlparse(url)
if not parsed.hostname:
raise ValueError(f"URL missing hostname: {url}")

try:
ip = socket.gethostbyname(parsed.hostname)
ip_obj = ipaddress.ip_address(ip)
if ip_obj.is_private or ip_obj.is_loopback:
if not os.environ.get("PYTEST_CURRENT_TEST"):
raise ValueError(f"URL points to a private or loopback IP: {ip}")
except socket.gaierror:
pass # Will fail to connect anyway
Comment on lines +132 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿฉบ Stability & Availability | ๐ŸŸ  Major | ๐Ÿ—๏ธ Heavy lift

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 \
  '\bdeadline\b|socket\.gethostbyname|socket\.getaddrinfo' \
  scripts/ci/sandboxed_web_e2e.py

Repository: ContextualWisdomLab/.github

Length of output: 1090


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target file ---'
sed -n '1,240p' scripts/ci/sandboxed_web_e2e.py

printf '%s\n' '--- related tests and call sites ---'
rg -n -C 4 \
  'wait_for_url|sandboxed_web_e2e|PYTEST_CURRENT_TEST|socket\.setdefaulttimeout|settimeout|timeout=' \
  scripts tests .github 2>/dev/null || true

Repository: ContextualWisdomLab/.github

Length of output: 50383


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- main cleanup and readiness flow ---'
sed -n '225,320p' scripts/ci/sandboxed_web_e2e.py

printf '%s\n' '--- focused sandboxed_web_e2e tests ---'
sed -n '120,235p' tests/test_repository_branch_coverage_execution_sandboxes.py

printf '%s\n' '--- all direct tests for wait_for_url ---'
rg -n -C 8 'wait_for_url' tests

Repository: ContextualWisdomLab/.github

Length of output: 20974


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("scripts/ci/sandboxed_web_e2e.py")
tree = ast.parse(path.read_text(encoding="utf-8"))

wait = next(
    node for node in ast.walk(tree)
    if isinstance(node, ast.FunctionDef) and node.name == "wait_for_url"
)
calls = [
    (node.lineno, ast.unparse(node))
    for node in ast.walk(wait)
    if isinstance(node, ast.Call)
]
print("wait_for_url calls:")
for item in sorted(calls):
    print(item)

main = next(
    node for node in ast.walk(tree)
    if isinstance(node, ast.FunctionDef) and node.name == "main"
)
try_nodes = [
    node for node in ast.walk(main)
    if isinstance(node, ast.Try) and any(isinstance(h, ast.ExceptHandler) for h in node.handlers)
]
finally_nodes = [
    node for node in ast.walk(main)
    if isinstance(node, ast.Try) and node.finalbody
]
dns_line = next(
    node.lineno for node in ast.walk(wait)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Attribute)
    and node.func.attr == "gethostbyname"
)
deadline_line = next(
    node.lineno for node in ast.walk(wait)
    if isinstance(node, ast.Assign)
    and any(
        isinstance(target, ast.Name) and target.id == "deadline"
        for target in node.targets
    )
)
print(f"gethostbyname_line={dns_line}")
print(f"deadline_assignment_line={deadline_line}")
print(f"main_try_except_blocks={len(try_nodes)}")
print(f"main_finally_blocks={len(finally_nodes)}")
print("deadline_after_dns=", dns_line < deadline_line)
print(
    "cleanup_calls_in_main_finally=",
    sorted(
        {
            node.func.id
            for final_try in finally_nodes
            for node in ast.walk(final_try)
            if isinstance(node, ast.Call)
            and isinstance(node.func, ast.Name)
            and node.func.id in {"stop_service", "shutil.rmtree"}
        }
    ),
)
PY

Repository: ContextualWisdomLab/.github

Length of output: 1020


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("scripts/ci/sandboxed_web_e2e.py")
tree = ast.parse(path.read_text(encoding="utf-8"))

wait = next(
    node for node in ast.walk(tree)
    if isinstance(node, ast.FunctionDef) and node.name == "wait_for_url"
)
calls = [
    (node.lineno, ast.unparse(node))
    for node in ast.walk(wait)
    if isinstance(node, ast.Call)
]
print("wait_for_url calls:")
for item in sorted(calls):
    print(item)

main = next(
    node for node in ast.walk(tree)
    if isinstance(node, ast.FunctionDef) and node.name == "main"
)
finally_nodes = [
    node for node in ast.walk(main)
    if isinstance(node, ast.Try) and node.finalbody
]
dns_line = next(
    node.lineno for node in ast.walk(wait)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Attribute)
    and node.func.attr == "gethostbyname"
)
deadline_line = next(
    node.lineno for node in ast.walk(wait)
    if isinstance(node, ast.Assign)
    and any(
        isinstance(target, ast.Name) and target.id == "deadline"
        for target in node.targets
    )
)
print(f"gethostbyname_line={dns_line}")
print(f"deadline_assignment_line={deadline_line}")
print(f"deadline_after_dns={dns_line < deadline_line}")
print(
    "cleanup_calls_in_main_finally=",
    sorted(
        {
            node.func.id
            for final_try in finally_nodes
            for node in ast.walk(final_try)
            if isinstance(node, ast.Call)
            and isinstance(node.func, ast.Name)
            and node.func.id in {"stop_service", "shutil.rmtree"}
        }
    ),
)
PY

Repository: ContextualWisdomLab/.github

Length of output: 972


DNS ์กฐํšŒ๊ฐ€ startup-timeout์„ ์šฐํšŒํ•˜์ง€ ์•Š๊ฒŒ ํ•˜์„ธ์š”.

socket.gethostbyname()์ด deadline ๊ณ„์‚ฐ๋ณด๋‹ค ๋จผ์ € ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. DNS resolver๊ฐ€ ์ง€์—ฐ๋˜๋ฉด readiness ๊ฒ€์‚ฌ๊ฐ€ startup-timeout๋ณด๋‹ค ์˜ค๋ž˜ ์ฐจ๋‹จ๋˜๊ณ  main์˜ ์„œ๋น„์Šค ์ •๋ฆฌ๊ฐ€ ์‹คํ–‰๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. deadline์„ DNS ์กฐํšŒ ์ „์— ๊ณ„์‚ฐํ•˜๊ณ , resolver ์ž์ฒด์— ๋ช…์‹œ์  ์ œํ•œ ์‹œ๊ฐ„์„ ์ ์šฉํ•˜์„ธ์š”.

๐Ÿค– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/ci/sandboxed_web_e2e.py` around lines 132 - 139, Update the
readiness-check flow around socket.gethostbyname() to calculate the startup
deadline before DNS resolution and enforce an explicit timeout on the resolver.
Ensure delayed DNS lookup cannot block beyond startup-timeout, and preserve the
existing private/loopback validation and gaierror handling.


Comment on lines +132 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ”’ Security & Privacy | ๐Ÿ”ด Critical | ๐Ÿ—๏ธ Heavy lift

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 \
  'socket\.gethostbyname|socket\.getaddrinfo|opener\.open|PYTEST_CURRENT_TEST' \
  scripts/ci/sandboxed_web_e2e.py

Repository: ContextualWisdomLab/.github

Length of output: 1245


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target file outline ---'
ast-grep outline scripts/ci/sandboxed_web_e2e.py

printf '%s\n' '--- target implementation ---'
sed -n '1,190p' scripts/ci/sandboxed_web_e2e.py

printf '%s\n' '--- related tests and callers ---'
rg -n -C 5 \
  'sandboxed_web_e2e|wait_for|NoRedirectHandler|PYTEST_CURRENT_TEST|gethostbyname|getaddrinfo' \
  scripts tests .github 2>/dev/null || true

Repository: ContextualWisdomLab/.github

Length of output: 50383


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- readiness tests ---'
sed -n '100,275p' tests/test_sandboxed_web_e2e.py

printf '%s\n' '--- configuration and workflow call sites ---'
rg -n -C 4 \
  'sandboxed_web_e2e\.py|backend-ready-url|frontend-ready-url|backend_ready_url|frontend_ready_url' \
  .github scripts tests \
  -g '*.yml' -g '*.yaml' -g '*.py' -g '*.sh' -g '*.md' 2>/dev/null | head -n 240

printf '%s\n' '--- coverage configuration ---'
rg -n -C 3 \
  'coverage|interrogate|testpaths|scripts/ci' \
  pyproject.toml setup.cfg tox.ini .coveragerc Makefile 2>/dev/null || true

Repository: ContextualWisdomLab/.github

Length of output: 20508


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
import inspect
import ipaddress
import socket
import urllib.request
import http.client

path = "scripts/ci/sandboxed_web_e2e.py"
tree = ast.parse(open(path, encoding="utf-8").read(), filename=path)
wait = next(
    node for node in ast.walk(tree)
    if isinstance(node, ast.FunctionDef) and node.name == "wait_for_url"
)

checks = {
    "uses_gethostbyname": False,
    "suppresses_gaierror": False,
    "opens_original_url": False,
}
for node in ast.walk(wait):
    if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
        if node.func.attr == "gethostbyname":
            checks["uses_gethostbyname"] = True
        if node.func.attr == "open" and node.args:
            checks["opens_original_url"] = isinstance(node.args[0], ast.Name) and node.args[0].id == "url"
    if isinstance(node, ast.ExceptHandler):
        if isinstance(node.type, ast.Attribute) and node.type.attr == "gaierror":
            checks["suppresses_gaierror"] = any(isinstance(stmt, ast.Pass) for stmt in node.body)

print("AST checks:", checks)
print("IPv6 literal gethostbyname behavior:")
try:
    socket.gethostbyname("::1")
except socket.gaierror as exc:
    print(type(exc).__name__, str(exc))
print("ipaddress ::1:", ipaddress.ip_address("::1"), "loopback=", ipaddress.ip_address("::1").is_loopback)

print("urllib connection resolution call:")
source = inspect.getsource(http.client.HTTPConnection.connect)
for line in source.splitlines():
    if "create_connection" in line or "self.host" in line:
        print(line.strip())

print("all checks passed:", all(checks.values()))
PY

Repository: ContextualWisdomLab/.github

Length of output: 550


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
import inspect
import ipaddress
import socket
import http.client

path = "scripts/ci/sandboxed_web_e2e.py"
tree = ast.parse(open(path, encoding="utf-8").read(), filename=path)
wait = next(
    node for node in ast.walk(tree)
    if isinstance(node, ast.FunctionDef) and node.name == "wait_for_url"
)

checks = {
    "uses_gethostbyname": False,
    "suppresses_gaierror": False,
    "opens_original_url": False,
}
for node in ast.walk(wait):
    if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
        if node.func.attr == "gethostbyname":
            checks["uses_gethostbyname"] = True
        if node.func.attr == "open" and node.args:
            checks["opens_original_url"] = isinstance(node.args[0], ast.Name) and node.args[0].id == "url"
    if isinstance(node, ast.ExceptHandler):
        if isinstance(node.type, ast.Attribute) and node.type.attr == "gaierror":
            checks["suppresses_gaierror"] = any(isinstance(stmt, ast.Pass) for stmt in node.body)

print("AST checks:", checks)
print("IPv6 literal gethostbyname behavior:")
try:
    socket.gethostbyname("::1")
except socket.gaierror as exc:
    print(type(exc).__name__, str(exc))
print("ipaddress ::1:", ipaddress.ip_address("::1"), "loopback=", ipaddress.ip_address("::1").is_loopback)

print("urllib connection resolution call:")
source = inspect.getsource(http.client.HTTPConnection.connect)
for line in source.splitlines():
    if "create_connection" in line or "self.host" in line:
        print(line.strip())

print("all checks passed:", all(checks.values()))
PY

Repository: ContextualWisdomLab/.github

Length of output: 550


๊ฒ€์ฆํ•œ ์ฃผ์†Œ๋ฅผ ์‹ค์ œ ์—ฐ๊ฒฐ์— ๊ณ ์ •ํ•˜๊ณ  DNS ์˜ค๋ฅ˜๋ฅผ ๊ฑฐ๋ถ€ํ•˜์„ธ์š”.

socket.gethostbyname(parsed.hostname)์€ IPv4 ์ฃผ์†Œ ํ•˜๋‚˜๋งŒ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. IPv6 ์ฃผ์†Œ๋Š” socket.gaierror๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ ๊ฒ€์‚ฌ๋ฅผ ์šฐํšŒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. opener.open(url, ...)์€ ์›๋ž˜ hostname์„ ๋‹ค์‹œ ํ•ด์„ํ•˜๋ฏ€๋กœ, ์—ฌ๋Ÿฌ A/AAAA ๋ ˆ์ฝ”๋“œ ๋˜๋Š” DNS rebinding์œผ๋กœ private ๋˜๋Š” loopback ์ฃผ์†Œ์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ชจ๋“  ์ฃผ์†Œ๋ฅผ socket.getaddrinfo(..., family=socket.AF_UNSPEC, type=socket.SOCK_STREAM)์œผ๋กœ ํ™•์ธํ•˜๊ณ , DNS ์˜ค๋ฅ˜์™€ ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ์ฃผ์†Œ๋ฅผ ๊ฑฐ๋ถ€ํ•˜์„ธ์š”. ์‹ค์ œ HTTP ์—ฐ๊ฒฐ์€ ๊ฒ€์ฆํ•œ ์ฃผ์†Œ๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. IPv4, IPv6, ๋‹ค์ค‘ ๋ ˆ์ฝ”๋“œ, DNS ์˜ค๋ฅ˜ ๋ฐ rebinding์„ ๊ฒ€์ฆํ•˜๋Š” ํ…Œ์ŠคํŠธ๋„ ์ถ”๊ฐ€ํ•˜์„ธ์š”. ์ƒˆ helper์—๋Š” docstring์„ ์ถ”๊ฐ€ํ•˜๊ณ  100% ์ปค๋ฒ„๋ฆฌ์ง€๋ฅผ ์œ ์ง€ํ•˜์„ธ์š”.

๐Ÿค– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/ci/sandboxed_web_e2e.py` around lines 132 - 140, Replace the hostname
check around parsed.hostname with a helper that resolves all IPv4 and IPv6
stream addresses via socket.getaddrinfo using AF_UNSPEC, rejects DNS resolution
errors and any private or loopback result, and documents this behavior with a
docstring. Ensure opener.open connects using the validated address rather than
re-resolving the original hostname, while preserving the intended test-only
exception if required. Add full-coverage tests for IPv4, IPv6, multiple records,
DNS failures, and rebinding.

deadline = time.monotonic() + timeout
opener = urllib.request.build_opener(NoRedirectHandler())
while time.monotonic() < deadline:
Expand Down
Loading