diff --git a/.jules/sentinel.md b/.jules/sentinel.md index be2dfa4bb..f12308142 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -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-04 - Prevent Secret Leakage in Subprocess Error Traces +**Vulnerability:** 정보 노출 / 시크릿 유출 (Information Disclosure / Secret Leakage) +**Learning:** 서브프로세스 명령어가 시간 초과되거나 실패할 때(예: `TimeoutExpired`), 캡처된 `stdout` 및 `stderr`를 단순히 출력하게 되면 CI 로그에 민감한 자격 증명, API 키 또는 토큰이 의도치 않게 노출될 수 있습니다. redaction 도구에 대해 `ImportError` 예외 처리에 의존할 경우, 조용히 실패하여 redaction 과정을 우회할 위험이 있습니다. +**Prevention:** 서브프로세스 출력이나 시간 초과 에러 로그를 출력하기 전에는 항상 `scripts.ci.redact_sensitive_log.redact_text`를 사용하여 민감한 토큰을 스크러빙해야 합니다. redaction 모듈이 무조건적으로 임포트되도록 보장하여(예: `sys.path`에 저장소 루트를 명시적으로 추가하여), 임포트 실패 시 안전하게 시스템을 종료하고 필터링되지 않은 로그가 노출될 가능성을 원천 차단하십시오. diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index aace18d45..3650893c3 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -14,6 +14,9 @@ from collections.abc import Sequence from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.ci.redact_sensitive_log import redact_text DEFAULT_IGNORE = ( ".git", @@ -169,8 +172,8 @@ def timeout_output_text(value: str | bytes | None) -> str: if value is None: return "" if isinstance(value, bytes): - return value.decode(errors="replace") - return value + return redact_text(value.decode(errors="replace")) + return redact_text(value) def emit_result( @@ -219,9 +222,9 @@ def main(argv: Sequence[str] | None = None) -> int: 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) diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index ae0c3105a..39e2fed16 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -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" @@ -232,9 +232,9 @@ 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: diff --git a/tests/test_sandboxed_verify.py b/tests/test_sandboxed_verify.py index c711f3489..b3a473417 100644 --- a/tests/test_sandboxed_verify.py +++ b/tests/test_sandboxed_verify.py @@ -85,6 +85,8 @@ def test_timeout_output_text_normalizes_subprocess_payloads(): assert sandboxed_verify.timeout_output_text(None) == "" assert sandboxed_verify.timeout_output_text(b"byte-output") == "byte-output" assert sandboxed_verify.timeout_output_text("text-output") == "text-output" + assert sandboxed_verify.timeout_output_text("my ghp_123456789012345678901234567890123456 token") == "my [REDACTED] token" + assert sandboxed_verify.timeout_output_text(b"my ghp_123456789012345678901234567890123456 token") == "my [REDACTED] token" def test_main_runs_command_in_copy_without_mutating_source(tmp_path, capsys): diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 6e092c293..2d947d280 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -399,7 +399,7 @@ def fake_start(label, command, cwd, env, logs_dir): return sandboxed_web_e2e.Service(label, command, DoneProcess(), log_path) def fake_run_shell(command, cwd, env, timeout): - raise subprocess.TimeoutExpired(command, timeout, output=b"e2e-out", stderr=b"e2e-err") + raise subprocess.TimeoutExpired(command, timeout, output=b"e2e-out ghp_123456789012345678901234567890123456", stderr=b"e2e-err") monkeypatch.setattr(sandboxed_web_e2e, "start_service", fake_start) monkeypatch.setattr(sandboxed_web_e2e, "wait_for_url", lambda url, timeout, service: True) @@ -423,7 +423,8 @@ def fake_run_shell(command, cwd, env, timeout): captured = capsys.readouterr() assert exit_code == 124 - assert "e2e-out" in captured.out + assert "e2e-out [REDACTED]" in captured.out + assert "ghp_123456789012345678901234567890123456" not in captured.out assert "e2e-err" in captured.err assert "e2e command timed out after 3s" in captured.err @@ -470,7 +471,7 @@ def test_sandboxed_web_e2e_reports_e2e_timeout(monkeypatch, tmp_path, capsys): repo.mkdir() def fake_run_shell(command, cwd, env, timeout): - raise subprocess.TimeoutExpired(command, timeout, output="e2e-out", stderr="e2e-err") + raise subprocess.TimeoutExpired(command, timeout, output="e2e-out ghp_123456789012345678901234567890123456", stderr="e2e-err") monkeypatch.setattr(sandboxed_web_e2e, "run_shell", fake_run_shell) @@ -491,7 +492,8 @@ def fake_run_shell(command, cwd, env, timeout): captured = capsys.readouterr() assert exit_code == 124 - assert "e2e-out" in captured.out + assert "e2e-out [REDACTED]" in captured.out + assert "ghp_123456789012345678901234567890123456" not in captured.out assert "e2e-err" in captured.err assert "e2e command timed out after 1s" in captured.err assert "SANDBOXED_WEB_E2E_RESULT" in captured.out