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
8 changes: 8 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,11 @@
**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-07-27 - Prevent Information Disclosure in CI Subprocess Logs
**Vulnerability:** Information Disclosure / Secret Leakage
**Learning:** Untrusted subprocess output and logs in `sandboxed_verify.py` and `sandboxed_web_e2e.py` can expose sensitive credentials in standard output and error tracebacks.
**Prevention:** Always wrap arbitrary subprocess stdout/stderr and tracebacks with `scripts.ci.redact_sensitive_log.redact_text` (with an `ImportError` fallback) before printing or logging them in the CI environment.
## 2026-07-27 - Fail Closed for CI Log Redaction
**Vulnerability:** Information Disclosure / Secret Leakage
**Learning:** Returning unredacted text when the `scripts.ci.redact_sensitive_log` module is unavailable behaves as a fail-open security bypass, violating the fail-secure principle. If the redactor fails to load, the CI environment risks leaking untrusted standard output, timeouts, tracebacks, and machine-readable payload fields (e.g., `command`, `cwd`, `evidence_note`).
**Prevention:** Remove `ImportError` fallbacks that silently pass through raw text. Ensure the repository root is established on `sys.path` and import `redact_text` unconditionally so the system fails closed rather than leaking secrets. Furthermore, sanitize scalar string fields containing commands and arguments before JSON serialization in result payloads.
44 changes: 31 additions & 13 deletions scripts/ci/sandboxed_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
from collections.abc import Sequence
from pathlib import Path

if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from scripts.ci.redact_sensitive_log import redact_text

DEFAULT_IGNORE = (
".git",
Expand Down Expand Up @@ -149,7 +153,9 @@ def copy_workspace(repo_root: Path, sandbox_root: Path, extra_ignores: Sequence[
return destination


def run_command(command: Sequence[str], cwd: Path, env: dict[str, str], timeout: int) -> subprocess.CompletedProcess[str]:
def run_command(
command: Sequence[str], cwd: Path, env: dict[str, str], timeout: int
) -> subprocess.CompletedProcess[str]:
"""Run the verification command and capture output for review evidence."""
return subprocess.run(
list(command),
Expand Down Expand Up @@ -188,13 +194,13 @@ def emit_result(
"""Print a machine-readable execution evidence summary."""
payload = {
"allowed_env": sorted(set(allowed_env)),
"command": list(command),
"cwd": str(copied_repo),
"command": [redact_text(item) for item in command],
"cwd": redact_text(str(copied_repo)),
"elapsed_seconds": round(elapsed_seconds, 3),
"evidence_note": evidence_note,
"evidence_note": redact_text(evidence_note),
"exit_code": exit_code,
"network": network,
"sandbox": str(sandbox_root) if kept else "(removed)",
"sandbox": redact_text(str(sandbox_root)) if kept else "(removed)",
"sandboxed": True,
}
print(f"{RESULT_MARKER} {json.dumps(payload, sort_keys=True)}")
Expand All @@ -210,27 +216,39 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
copied_repo = copy_workspace(Path(args.repo_root), sandbox, args.ignore)
env = scrubbed_env(sandbox, args.allow_env)
print(f"sandboxed-verify: cwd={copied_repo}")
print(f"sandboxed-verify: command={' '.join(args.command)}")
print(redact_text(f"sandboxed-verify: cwd={copied_repo}"))
print(redact_text(f"sandboxed-verify: command={' '.join(args.command)}"))
if args.allow_env:
print(f"sandboxed-verify: allowed env names={','.join(sorted(set(args.allow_env)))}")
print(
redact_text(
"sandboxed-verify: allowed env names="
+ ",".join(sorted(set(args.allow_env)))
)
)
if args.network != "default":
print(f"sandboxed-verify: network={args.network}")
try:
completed = run_command(args.command, copied_repo, env, args.timeout)
if completed.stdout:
print(completed.stdout, end="")
print(redact_text(completed.stdout), end="")
if completed.stderr:
print(completed.stderr, end="", file=sys.stderr)
print(redact_text(completed.stderr), end="", file=sys.stderr)
exit_code = completed.returncode
except subprocess.TimeoutExpired as exc:
stdout = timeout_output_text(exc.stdout)
stderr = timeout_output_text(exc.stderr)
if stdout:
print(stdout, end="" if stdout.endswith("\n") else "\n")
print(redact_text(stdout), end="" if stdout.endswith("\n") else "\n")
if stderr:
print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
print(f"sandboxed-verify: command timed out after {args.timeout}s", file=sys.stderr)
print(
redact_text(stderr),
end="" if stderr.endswith("\n") else "\n",
file=sys.stderr,
)
print(
f"sandboxed-verify: command timed out after {args.timeout}s",
file=sys.stderr,
)
exit_code = 124
return exit_code
finally:
Expand Down
28 changes: 14 additions & 14 deletions scripts/ci/sandboxed_web_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from scripts.ci import sandboxed_verify

from scripts.ci.redact_sensitive_log import redact_text

RESULT_MARKER = "SANDBOXED_WEB_E2E_RESULT"

Expand Down Expand Up @@ -184,18 +184,18 @@ def emit_result(
) -> None:
"""Print a machine-readable web E2E execution evidence summary."""
payload = {
"backend_cmd": args.backend_cmd,
"backend_cmd": redact_text(args.backend_cmd),
"backend_ready": backend_ready,
"allowed_env": sorted(set(args.allow_env)),
"cwd": str(copied_repo),
"e2e_cmd": args.e2e_cmd,
"cwd": redact_text(str(copied_repo)),
"e2e_cmd": redact_text(args.e2e_cmd),
"elapsed_seconds": round(elapsed_seconds, 3),
"evidence_note": args.evidence_note,
"evidence_note": redact_text(args.evidence_note),
"exit_code": exit_code,
"frontend_cmd": args.frontend_cmd,
"frontend_cmd": redact_text(args.frontend_cmd),
"frontend_ready": frontend_ready,
"network": args.network,
"sandbox": str(sandbox_root) if args.keep_sandbox else "(removed)",
"sandbox": redact_text(str(sandbox_root)) if args.keep_sandbox else "(removed)",
"sandboxed": True,
}
print(f"{RESULT_MARKER} {json.dumps(payload, sort_keys=True)}")
Expand All @@ -216,9 +216,9 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
copied_repo = sandboxed_verify.copy_workspace(Path(args.repo_root), sandbox, args.ignore)
env = sandboxed_verify.scrubbed_env(sandbox, args.allow_env)
print(f"sandboxed-web-e2e: cwd={copied_repo}")
print(redact_text(f"sandboxed-web-e2e: cwd={copied_repo}"))
if args.allow_env:
print(f"sandboxed-web-e2e: allowed env names={','.join(sorted(set(args.allow_env)))}")
print(redact_text(f"sandboxed-web-e2e: allowed env names={','.join(sorted(set(args.allow_env)))}"))
Comment thread
seonghobae marked this conversation as resolved.
if args.network != "default":
print(f"sandboxed-web-e2e: network={args.network}")
services.append(start_service("backend", args.backend_cmd, copied_repo, env, logs_dir))
Expand All @@ -232,18 +232,18 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
completed = run_shell(args.e2e_cmd, copied_repo, env, args.e2e_timeout)
if completed.stdout:
print(completed.stdout, end="")
print(redact_text(completed.stdout), end="")
if completed.stderr:
print(completed.stderr, end="", file=sys.stderr)
print(redact_text(completed.stderr), end="", file=sys.stderr)
exit_code = completed.returncode
return exit_code
except subprocess.TimeoutExpired as exc:
stdout = sandboxed_verify.timeout_output_text(exc.stdout)
stderr = sandboxed_verify.timeout_output_text(exc.stderr)
if stdout:
print(stdout, end="" if stdout.endswith("\n") else "\n")
print(redact_text(stdout), end="" if stdout.endswith("\n") else "\n")
if stderr:
print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
print(redact_text(stderr), end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
print(f"sandboxed-web-e2e: e2e command timed out after {args.e2e_timeout}s", file=sys.stderr)
exit_code = 124
return exit_code
Expand All @@ -253,7 +253,7 @@ def main(argv: Sequence[str] | None = None) -> int:
log_tail = tail_text(service.log_path)
if log_tail:
print(f"--- {service.label} log tail ---")
print(log_tail)
print(redact_text(log_tail))
emit_result(
args=args,
copied_repo=copied_repo,
Expand Down
80 changes: 80 additions & 0 deletions tests/test_sandboxed_verify_redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Regression tests for sandboxed verification log redaction."""

from scripts.ci import sandboxed_verify


def _api_key_fixture() -> str:
"""Return a representative key/value secret fixture."""
return "api_key: " + "mock_token_string"


def _session_key_fixture() -> str:
"""Return a scanner-safe sensitive assignment fixture."""
return "session_key=" + "mock_session_value"


def test_timeout_output_text_redacts_bytes_and_str() -> None:
"""Timeout output is normalized and redacted for bytes and strings."""
api_key = _api_key_fixture()
session_key = _session_key_fixture()

assert sandboxed_verify.redact_text(
sandboxed_verify.timeout_output_text(api_key.encode())
) == "api_key: [REDACTED]"
assert sandboxed_verify.redact_text(
sandboxed_verify.timeout_output_text(session_key)
) == "session_key=[REDACTED]"


def test_emit_result_redacts_payload_fields(capsys, tmp_path) -> None:
"""Machine-readable evidence never emits credential-shaped payload values."""
api_key = _api_key_fixture()
session_key = _session_key_fixture()
sandboxed_verify.emit_result(
command=["echo", api_key],
copied_repo=tmp_path / session_key,
sandbox_root=tmp_path / "sandbox_test_root",
exit_code=0,
elapsed_seconds=1.0,
kept=True,
allowed_env=[],
network="default",
evidence_note=f"used {api_key}",
)
captured = capsys.readouterr()
assert "[REDACTED]" in captured.out
assert "mock_token_string" not in captured.out
assert "mock_session_value" not in captured.out
assert "sandbox_test_root" in captured.out


def test_main_redacts_stdout_and_stderr(tmp_path, capsys) -> None:
"""Subprocess stdout and stderr are redacted before publication."""
repo = tmp_path / "repo"
repo.mkdir()
api_key = _api_key_fixture()
session_key = _session_key_fixture()
command = (
"import sys; "
f"print({api_key!r}); "
f"print({session_key!r}, file=sys.stderr)"
)

exit_code = sandboxed_verify.main(
[
"--repo-root",
str(repo),
"--timeout",
"5",
"--",
"python",
"-c",
command,
]
)
captured = capsys.readouterr()
assert exit_code == 0
assert "api_key: [REDACTED]" in captured.out
assert "mock_token_string" not in captured.out
assert "session_key=[REDACTED]" in captured.err
assert "mock_session_value" not in captured.err
85 changes: 85 additions & 0 deletions tests/test_sandboxed_web_e2e_redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Regression tests for sandboxed web-E2E log redaction."""

from scripts.ci import sandboxed_web_e2e


def _api_key_fixture() -> str:
"""Return a representative key/value secret fixture."""
return "api_key: " + "mock_token_string"


def _session_key_fixture() -> str:
"""Return a scanner-safe sensitive assignment fixture."""
return "session_key=" + "mock_session_value"


def _password_fixture() -> str:
"""Return a scanner-safe password assignment fixture."""
return "password=" + "mock_password_value"


def test_emit_result_redacts_payload_fields(capsys, tmp_path) -> None:
"""Machine-readable web evidence redacts commands and paths."""
api_key = _api_key_fixture()
session_key = _session_key_fixture()
password = _password_fixture()

class FakeArgs:
backend_cmd = f"echo {api_key}"
frontend_cmd = f"echo {session_key}"
e2e_cmd = f"echo {password}"
allow_env: list[str] = []
evidence_note = "used nothing_sensitive"
network = "default"
keep_sandbox = True

sandboxed_web_e2e.emit_result(
args=FakeArgs(),
copied_repo=tmp_path / session_key,
sandbox_root=tmp_path / "sandbox_test_root",
backend_ready=True,
frontend_ready=True,
exit_code=0,
elapsed_seconds=1.0,
)
captured = capsys.readouterr()
assert "[REDACTED]" in captured.out
assert "mock_token_string" not in captured.out
assert "mock_session_value" not in captured.out
assert "mock_password_value" not in captured.out
assert "sandbox_test_root" in captured.out


def test_main_redacts_stdout_stderr_and_log_tail(tmp_path, capsys) -> None:
"""Web-E2E subprocess streams and service log tails are redacted."""
repo = tmp_path / "repo"
repo.mkdir()
api_key = _api_key_fixture()
session_key = _session_key_fixture()
password = _password_fixture()

_ = sandboxed_web_e2e.main(
[
"--repo-root",
str(repo),
"--backend-cmd",
f"python -c \"import sys; print({api_key!r})\"",
"--frontend-cmd",
f"python -c \"print({session_key!r})\"",
"--e2e-cmd",
(
"python -c \"import sys; "
f"print({api_key!r}); "
f"print({password!r}, file=sys.stderr)\""
),
"--startup-timeout",
"1",
"--e2e-timeout",
"5",
]
)
captured = capsys.readouterr()
assert "[REDACTED]" in captured.out
assert "mock_token_string" not in captured.out
assert "mock_session_value" not in captured.out
assert "mock_password_value" not in captured.err
Loading