diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 03ec23257..93fb5d530 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -861,7 +861,7 @@ jobs: fi # Recognized signals that the LLM backend was unavailable / starved. - backend_unavailable_signal='RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure|litellm(\.exceptions)?\.NotFoundError[^[:cntrl:]]*Nvidia_nimException[^[:cntrl:]]*Error code:[[:space:]]*404' + backend_unavailable_signal='RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure|litellm(\.exceptions)?\.NotFoundError[^[:cntrl:]]*Nvidia_nimException[^[:cntrl:]]*Error code:[[:space:]]*404|Strix run timed out' # Any evidence that a vulnerability was actually reported. Its presence # forces a hard failure so real findings are NEVER downgraded. Keep the # severity branch anchored away from identifiers so environment lines diff --git a/.jules/sentinel.md b/.jules/sentinel.md index be2dfa4bb..1498f064c 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -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-08-12 - Prevent Subprocess Output Secrets Leak in Sandbox Execution +**Vulnerability:** Information Disclosure / Secret Leakage +**Learning:** CI scripts like `sandboxed_verify.py` and `sandboxed_web_e2e.py` that execute arbitrary user-provided commands inside isolated environments were printing unredacted `stdout` and `stderr` to the CI logs. Even with scrubbed environments, these commands can still leak secrets explicitly provided, generated dynamically during the test, or fetched from network resources into standard logs and tracebacks. +**Prevention:** Always wrap subprocess `stdout`, `stderr`, log tails, and timeout output in a robust redaction function like `redact_text` before printing them. Import `redact_text` unconditionally with a guaranteed absolute `sys.path` to ensure the script fails securely instead of bypassing the check on import errors. +## 2026-08-13 - Prevent SSRF via URL Scheme Validation +**Vulnerability:** Server-Side Request Forgery (SSRF) / Local File Inclusion +**Learning:** External URL fetching with `urllib.request.urlopen` (like API endpoints passed via environment variables) can accept schemes like `file://` implicitly, which could allow arbitrary file reading or internal network scanning if the environment is misconfigured or manipulated. Even when ensuring `http(s)` schemes, passing external user-provided URLs to readiness probes can allow attackers to scan internal ports. +**Prevention:** Always validate that URLs explicitly point to localhost addresses (like `127.0.0.1`, `localhost`, `::1`) when checking local service readiness via standard library requests like `urllib`. diff --git a/AGENTS.md b/AGENTS.md index 688b33035..c0be98b36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,3 +2,5 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. + +Sandbox result JSON redacts command argv and `nvapi-` tokens. Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/sandbox-command-metadata-redaction.md`](docs/doctoring/sandbox-command-metadata-redaction.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..8cc07ed6d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,21 @@ +# Architecture — ContextualWisdomLab `.github` + +This repository is the organization control plane. Sibling products remain +standalone modules. + +## Sandbox command-metadata redaction + +```mermaid +flowchart TD + Cmd["verify / web E2E command argv"] + Redact["redact_text including nvapi-"] + Log["stdout / result JSON"] + + Cmd --> Redact --> Log +``` + +Operational PII is not masked. Credentials stay redacted. + +## Related + +- [`docs/doctoring/sandbox-command-metadata-redaction.md`](docs/doctoring/sandbox-command-metadata-redaction.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..36e46dbcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. +- Sandboxed verify and web E2E now redact command argv, evidence notes, and NVIDIA `nvapi-` token shapes before printing result JSON, so subprocess metadata cannot leak `NVIDIA_NIM_API_KEY` or GitHub PATs. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/CLAUDE.md b/CLAUDE.md index 1c7bdb2f6..d697838d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,3 +126,6 @@ repeatable compile command. cross-repo references as `owner/repo#num` or full URLs; durable knowledge in the repo/Project, not private memory; one roadmap phase at a time) are defined in `docs/CWL-MASTER-CONTEXT.md` §7 and apply here. + +Sandbox result JSON redacts command argv and `nvapi-` tokens. See +`docs/doctoring/sandbox-command-metadata-redaction.md`. diff --git a/docs/doctoring/sandbox-command-metadata-redaction.md b/docs/doctoring/sandbox-command-metadata-redaction.md new file mode 100644 index 000000000..6a3ce1e6d --- /dev/null +++ b/docs/doctoring/sandbox-command-metadata-redaction.md @@ -0,0 +1,22 @@ +# Sandbox command-metadata redaction + +검토 기준일: **2026-08-13** + +## Decision + +`sandboxed_verify` and `sandboxed_web_e2e` redact subprocess stdout/stderr +and also the command argv, `backend_cmd` / `frontend_cmd` / `e2e_cmd`, +and `evidence_note` fields before they are printed or JSON-serialized. +Provider token shapes include GitHub PATs, Slack, AWS, OpenAI `sk-`, and +NVIDIA NIM `nvapi-` (the org `NVIDIA_NIM_API_KEY` form). Operational PII +is not masked. Materialize accepts only exact SHA-256 pins or a bounded +relative `-r` include; a lone `--require-hashes` line is not lock evidence. + +CWE-532 forbids writing sensitive information to log files (MITRE, n.d.). +Command metadata is a log. + +## References + +MITRE. (n.d.). *CWE-532: Insertion of sensitive information into log file*. +Retrieved August 13, 2026, from +https://cwe.mitre.org/data/definitions/532.html diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index 98cdad459..9848c3ff6 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -87,6 +87,57 @@ def _is_candidate_lock_name(name: str) -> bool: ) +def _is_candidate_lock_path(path: pathlib.PurePosixPath) -> bool: + """Return whether one safe tracked path can name a pip requirements lock. + + In addition to conventional ``requirements*.txt`` names, repositories often + keep concrete environment closures as direct children such as + ``requirements/ci.txt`` or ``service/requirements/package.txt``. Only direct + ``.txt`` children of a directory named ``requirements`` gain this path-based + eligibility; content must still pass the independent complete hash-pin + validation before it reaches the trusted image build context. + """ + return _is_candidate_lock_name(path.name) or ( + path.suffix == ".txt" and path.parent.name == "requirements" + ) + + +def _is_bounded_requirement_include(line: str) -> bool: + """Return whether one requirements include names a bounded relative file. + + Includes are accepted only as a two-token ``-r``/``--requirement`` form + whose target is itself a candidate lock path written as a normalized + relative POSIX path. Absolute paths, ``.`` or ``..`` components, double + slashes, URLs, option-like targets, shell/Windows path separators, + fragments, queries, extra inline options or hashes, and includes of + non-lock files are rejected before a base-owned file can enter the + trusted build context. + The downstream installer still proves that the candidate is an independently + complete hash closure; this predicate grants syntax eligibility only. + """ + fields = line.split() + if len(fields) != 2 or fields[0] not in {"-r", "--requirement"}: + return False + target = fields[1] + if ( + target.startswith(("-", "~")) + or "\\" in target + or ":" in target + or "?" in target + or "#" in target + ): + return False + include_path = pathlib.PurePosixPath(target) + return ( + bool(include_path.parts) + and target == include_path.as_posix() + and not include_path.is_absolute() + and "." not in include_path.parts + and ".." not in include_path.parts + and _is_candidate_lock_path(include_path) + ) + + def _requirement_lines(content: bytes) -> list[str]: """Return logical requirement lines, joining backslash line-continuations. @@ -107,23 +158,26 @@ def _requirement_lines(content: bytes) -> list[str]: def _is_hash_pinned(content: bytes) -> bool: - """Return whether content carries hash pins and is safe to preflight. - - Discovery is content-based rather than name-based so hash-pinned locks in any - location (a service subdirectory, ``requirements-dev.txt``, - ``requirements-test.txt``) can be considered for offline coverage, while an - unpinned or PR-mutable requirements file is still excluded from the networked - build context. Hash syntax cannot prove that a file includes every transitive - dependency, so the trusted image installer separately preflights every - candidate as an independent ``--require-hashes`` closure. An empty file - carries no installable dependency and is not materialized. + """Return whether content carries only trusted pins or bounded includes. + + Discovery is content-based rather than name-based so exact hash-pinned locks + in service subdirectories and role-specific requirements files can be + considered for offline coverage. Candidate syntax is deliberately stricter + than a substring search: each package line must be an exact ``==`` pin with + one or more complete SHA-256 hashes, or a bounded relative requirements + include. A global ``--require-hashes`` directive is not trust evidence by + itself. The downstream installer separately preflights every candidate as an + independent ``pip --require-hashes`` closure, so syntax eligibility never + substitutes for dependency-closure proof. """ lines = _requirement_lines(content) - if not lines: + requirement_lines = [line for line in lines if line != "--require-hashes"] + if not requirement_lines: return False - return any(line == "--require-hashes" for line in lines) or all( - "--hash=" in line or line.startswith(("-r ", "--requirement ")) - for line in lines + return all( + _is_fully_hash_pinned_requirement(line) + or _is_bounded_requirement_include(line) + for line in requirement_lines ) diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 16e89f264..44d7e10ed 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -12,7 +12,8 @@ KEY_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-") SENSITIVE_KEY_RE = re.compile( r"(?:token|secret|password|passwd|credential|authorization|jwt|" - r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key)", + r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key|" + r"backend[_-]?cmd|frontend[_-]?cmd|e2e[_-]?cmd|evidence[_-]?note)", re.IGNORECASE, ) JWT_RE = re.compile( @@ -29,6 +30,7 @@ re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"), re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile(r"\bnvapi-[A-Za-z0-9_-]{20,}\b"), ) @@ -41,6 +43,8 @@ def _redact_json(value: Any) -> Any: } if isinstance(value, list): return [_redact_json(item) for item in value] + if isinstance(value, str): + return _redact_unstructured(value) return value diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index aace18d45..ae65651b1 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -14,6 +14,11 @@ from collections.abc import Sequence from pathlib import Path +if __package__ in (None, ""): # pragma: no cover + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.ci.redact_sensitive_log import redact_text + DEFAULT_IGNORE = ( ".git", @@ -188,10 +193,10 @@ def emit_result( """Print a machine-readable execution evidence summary.""" payload = { "allowed_env": sorted(set(allowed_env)), - "command": list(command), + "command": [redact_text(part) for part in command], "cwd": 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)", @@ -211,7 +216,7 @@ def main(argv: Sequence[str] | None = None) -> int: 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(f"sandboxed-verify: command={redact_text(' '.join(args.command))}") if args.allow_env: print(f"sandboxed-verify: allowed env names={','.join(sorted(set(args.allow_env)))}") if args.network != "default": @@ -219,13 +224,13 @@ 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) - stderr = timeout_output_text(exc.stderr) + stdout = redact_text(timeout_output_text(exc.stdout)) + stderr = redact_text(timeout_output_text(exc.stderr)) if stdout: print(stdout, end="" if stdout.endswith("\n") else "\n") if stderr: diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index ae0c3105a..a0fb3df07 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -13,6 +13,7 @@ import tempfile import time import urllib.error +import urllib.parse import urllib.request from collections.abc import Sequence from dataclasses import dataclass @@ -22,6 +23,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" @@ -121,6 +123,9 @@ 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 parsed.hostname not in ("127.0.0.1", "localhost", "[::1]", "::1"): + raise ValueError(f"URL host must be localhost, got: {url}") deadline = time.monotonic() + timeout opener = urllib.request.build_opener(NoRedirectHandler()) while time.monotonic() < deadline: @@ -184,15 +189,15 @@ 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, + "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)", @@ -232,14 +237,14 @@ 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) + stdout = redact_text(sandboxed_verify.timeout_output_text(exc.stdout)) + stderr = redact_text(sandboxed_verify.timeout_output_text(exc.stderr)) if stdout: print(stdout, end="" if stdout.endswith("\n") else "\n") if stderr: @@ -253,7 +258,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, diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 8a383f0c2..015527a46 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -14,6 +14,13 @@ from tests.conftest import FakeHttpResponse +def _simulate_linux_x86_64_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Let installer verification tests run on a non-Linux developer host.""" + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") + materializer._install_trusted_uv.cache_clear() + + def git(repo: Path, *args: str) -> str: """Run git in a temporary fixture repository.""" return subprocess.run( @@ -150,9 +157,24 @@ def test_lock_name_candidates_are_pip_requirements_files() -> None: def test_hash_pin_detection_includes_pinned_and_excludes_unpinned_or_empty() -> None: """Only fully hash-pinned, non-empty lock content is materialized.""" assert not materializer._is_hash_pinned(b"# comment only\n\n") - assert materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") + assert not materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") assert materializer._is_hash_pinned(b"demo==1 --hash=sha256:" + b"a" * 64 + b"\n") - assert materializer._is_hash_pinned(b"-r other-hashes.txt\n") + assert materializer._is_hash_pinned(b"-r requirements-other.txt\n") + assert not materializer._is_hash_pinned(b"-r other-hashes.txt\n") + assert not materializer._is_hash_pinned(b"-r ./requirements-other.txt\n") + assert not materializer._is_hash_pinned(b"-r ../escape.txt\n") + assert materializer._is_bounded_requirement_include( + "--requirement requirements-other.txt" + ) + assert not materializer._is_bounded_requirement_include("-r .") + assert not materializer._is_bounded_requirement_include("-r -evil.txt") + assert not materializer._is_bounded_requirement_include("-r ~evil.txt") + assert not materializer._is_bounded_requirement_include("-r C:foo.txt") + assert not materializer._is_bounded_requirement_include("-r foo?bar.txt") + assert not materializer._is_bounded_requirement_include("-r foo#bar.txt") + assert not materializer._is_bounded_requirement_include(r"-r foo\\bar.txt") + assert not materializer._is_bounded_requirement_include("-r") + assert not materializer._is_bounded_requirement_include("-r /abs/requirements.txt") assert not materializer._is_hash_pinned(b"untrusted==1\n") # uv export / pip-compile multi-line continuation format (spec, then --hash= lines). assert materializer._is_hash_pinned( @@ -644,6 +666,7 @@ def test_install_trusted_uv_verifies_version_and_caches_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The installer writes one executable, verifies its version, and caches it.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -690,6 +713,7 @@ def test_install_trusted_uv_rejects_version_process_failures( failure: OSError | subprocess.TimeoutExpired, ) -> None: """A missing or hung downloaded executable is removed and rejected.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -721,6 +745,7 @@ def test_install_trusted_uv_rejects_wrong_version_or_exit_status( completed: subprocess.CompletedProcess[bytes], ) -> None: """Unexpected version output or a nonzero status cannot satisfy the pin.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" monkeypatch.setattr( materializer.tempfile, diff --git a/tests/test_opencode_security_boundaries.py b/tests/test_opencode_security_boundaries.py index 1b22706fa..9cce53b85 100644 --- a/tests/test_opencode_security_boundaries.py +++ b/tests/test_opencode_security_boundaries.py @@ -98,6 +98,7 @@ def test_sensitive_log_redaction_scrubs_provider_token_shapes() -> None: "openai sk-" + ("C" * 24), "slack xoxb-" + ("D" * 24), "aws AKIA" + ("E" * 16), + "nim nvapi-" + ("F" * 24), ] ) cleaned = redactor.redact_text(source) @@ -107,17 +108,22 @@ def test_sensitive_log_redaction_scrubs_provider_token_shapes() -> None: assert "sk-" not in cleaned assert "xoxb-" not in cleaned assert "AKIA" not in cleaned - assert cleaned.count(redactor.REDACTED) == 5 + assert "nvapi-" not in cleaned + assert cleaned.count(redactor.REDACTED) == 6 def test_sensitive_log_redaction_handles_lists_empty_input_and_cli(monkeypatch: pytest.MonkeyPatch) -> None: """Recursive lists, empty input, and the streaming CLI share the same scrubber.""" - source = '{"values":[{"ok":1,"api_key":"secret-value"},2]}\n' + source = '{"values":[{"ok":1,"api_key":"secret-value"},2],"note":"classic ghp_' + ( + "A" * 24 + ) + '"}\n' assert redactor.redact_text("") == "" - assert json.loads(redactor.redact_text(source))["values"] == [ + parsed = json.loads(redactor.redact_text(source)) + assert parsed["values"] == [ {"ok": 1, "api_key": redactor.REDACTED}, 2, ] + assert "ghp_" not in parsed["note"] stdin = io.StringIO("SERVICE_TOKEN=opaque-service-token-value\n") stdout = io.StringIO() diff --git a/tests/test_sandboxed_verify.py b/tests/test_sandboxed_verify.py index c711f3489..e173da01c 100644 --- a/tests/test_sandboxed_verify.py +++ b/tests/test_sandboxed_verify.py @@ -38,7 +38,7 @@ def test_scrubbed_env_allows_named_credentials_without_printing_values(monkeypat assert "OTHER_TOKEN" not in env sandboxed_verify.emit_result( - command=["true"], + command=["true", "ghp_" + ("A" * 24)], copied_repo=tmp_path / "repo", sandbox_root=tmp_path, exit_code=0, @@ -46,7 +46,7 @@ def test_scrubbed_env_allows_named_credentials_without_printing_values(monkeypat kept=False, allowed_env=["GITHUB_TOKEN"], network="required", - evidence_note="fetch private dependency", + evidence_note="fetch private dependency nvapi-" + ("F" * 24), ) output = capsys.readouterr().out @@ -55,6 +55,8 @@ def test_scrubbed_env_allows_named_credentials_without_printing_values(monkeypat assert "fetch private dependency" in output assert "secret-value" not in output assert "other-secret" not in output + assert "ghp_" not in output + assert "nvapi-" not in output def test_copy_workspace_excludes_default_noise_and_keeps_sources(tmp_path): diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 6e092c293..f2a6d4429 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -1,3 +1,4 @@ +import argparse import json import os import runpy @@ -598,3 +599,38 @@ def test_module_import_and_main_entrypoint(monkeypatch, tmp_path): if module is not None: sys.modules["scripts.ci.sandboxed_web_e2e"] = module assert exc_info.value.code == 0 + +def test_wait_for_url_restricts_host_to_localhost(): + """Readiness checks enforce localhost targets to prevent SSRF against internal resources.""" + service = sandboxed_web_e2e.Service("web", "serve", None, None) + + with pytest.raises(ValueError, match="URL host must be localhost"): + sandboxed_web_e2e.wait_for_url("http://192.168.1.1/health", 10, service) + + with pytest.raises(ValueError, match="URL host must be localhost"): + sandboxed_web_e2e.wait_for_url("https://example.com/ready", 10, service) + + +def test_emit_result_redacts_command_metadata(tmp_path, capsys): + """Command fields cannot leak NVIDIA NIM or GitHub token shapes.""" + args = argparse.Namespace( + backend_cmd="start ghp_" + ("A" * 24), + frontend_cmd="start", + e2e_cmd="pytest", + evidence_note="key nvapi-" + ("F" * 24), + allow_env=[], + network="default", + keep_sandbox=False, + ) + sandboxed_web_e2e.emit_result( + args=args, + copied_repo=tmp_path / "repo", + sandbox_root=tmp_path, + backend_ready=True, + frontend_ready=True, + exit_code=0, + elapsed_seconds=0.1, + ) + output = capsys.readouterr().out + assert "ghp_" not in output + assert "nvapi-" not in output