From e6b55d79ae6c3b56250ddc4d422731230d5e5c0a Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 5 Aug 2026 13:28:14 +0530 Subject: [PATCH 1/3] Enforce export sandbox and dependency audits --- .github/workflows/security-audit.yml | 68 +++++++++++++++++++++ Makefile | 4 +- SECURITY.md | 1 + python/xy/export.py | 32 +++------- scripts/verify_dependency_lock_inventory.py | 61 ++++++++++++++++++ scripts/verify_local.py | 5 ++ spec/process/production-readiness.md | 3 +- spec/process/security-audit-2026-07-06.md | 10 +++ tests/test_figure.py | 14 ++--- tests/test_image_export.py | 19 ++++++ 10 files changed, 180 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/security-audit.yml create mode 100644 scripts/verify_dependency_lock_inventory.py diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 00000000..f2579ac0 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,68 @@ +name: Dependency security audit + +on: + pull_request: + paths: + - ".github/workflows/security-audit.yml" + - "Cargo.lock" + - "**/Cargo.lock" + - "**/package-lock.json" + - "**/bun.lock" + - "**/uv.lock" + - "**/requirements*.lock" + - "pyproject.toml" + - "scripts/verify_dependency_lock_inventory.py" + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + name: Audit committed dependency locks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - name: Verify audit inventory + run: python3 scripts/verify_dependency_lock_inventory.py + + - name: Audit root Python environment + run: | + uv sync --locked --all-extras --all-groups + uv run --with pip-audit pip-audit --progress-spinner off + + - name: Audit documentation Python environment + run: | + uv sync --project docs/app --frozen --group dev + uv run --project docs/app --no-sync --with pip-audit pip-audit --progress-spinner off + + - name: Audit benchmark Python lock + run: uvx --from pip-audit pip-audit -r benchmarks/requirements-ci.lock --progress-spinner off + + - name: Audit Rust locks + run: | + cargo install cargo-audit --locked + cargo audit --locked + cargo audit --file examples/osm/osmium-rs/Cargo.lock + + - name: Audit npm lock + run: npm audit --package-lock-only --audit-level=high + + - name: Audit Bun lock + run: | + npm install --global bun@1.2.20 + cd docs/app/reflex.lock + bun install --frozen-lockfile + bun audit --audit-level=high diff --git a/Makefile b/Makefile index 27e70c38..ce414985 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ help: ' make check-conformance run accessibility + Chromium/Firefox/WebKit conformance' \ ' make check-docs run documentation examples' \ ' make check-examples run canonical API examples and Reflex asset registry checks' \ - ' make check-security run standalone HTML safety and client text-sink checks' \ + ' make check-security run export safety and dependency lock inventory checks' \ ' make check-errors run public error, LOD, and mutation-safety tests' \ ' make check-api run lazy public API and type-surface checks' \ ' make check-import run import-time and dependency-boundary checks' \ @@ -83,7 +83,7 @@ check-pyplot: $(PYTHON) -m pytest tests/pyplot -q check-security: - $(PYTHON) scripts/verify_local.py --only security_export + $(PYTHON) scripts/verify_local.py --only security_export,dependency_lock_inventory check-errors: $(PYTHON) scripts/verify_local.py --only error_safety diff --git a/SECURITY.md b/SECURITY.md index 3fd2f01b..9b9a4a03 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,6 +31,7 @@ metadata is rejected. `make check-security`. - `Figure.to_png` launches local Chromium with the browser sandbox enabled by default; `sandbox=False` is an explicit caller opt-out for trusted HTML. + A sandboxed launch failure fails closed and is never retried unsandboxed. - The native core is a local in-process C-ABI library; it processes only data already in the caller's process and performs no I/O or network access. - The audit trail lives in `spec/process/security-audit-2026-07-06.md`. diff --git a/python/xy/export.py b/python/xy/export.py index f2f3dec1..e8919ad1 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -610,22 +610,11 @@ def html_to_png( ) if not shot.exists(): first_tail = (proc.stderr or "")[-500:] - if sandbox: - retry_args = list(args) - retry_args.insert(2, "--no-sandbox") - proc = subprocess.run( - retry_args, - capture_output=True, - text=True, - timeout=timeout_s, - ) - if not shot.exists(): - tail = (proc.stderr or "")[-500:] - if sandbox: - tail = f"sandboxed launch failed: {first_tail}\nno-sandbox retry failed: {tail}" - raise RuntimeError( - f"Chromium produced no screenshot (exit {proc.returncode}): {tail}" - ) + mode = "sandboxed" if sandbox else "unsandboxed" + raise RuntimeError( + f"Chromium {mode} launch produced no screenshot " + f"(exit {proc.returncode}): {first_tail}" + ) data = shot.read_bytes() if data[:8] != b"\x89PNG\r\n\x1a\n": raise RuntimeError("screenshot output was not a PNG") @@ -1032,7 +1021,7 @@ def _browser_html(fig: "Figure", custom_css: Optional[str], background: Optional def _browser_session(*, gl: str, sandbox: bool) -> "Any": - """One launched ChromiumSession, mirroring `html_to_png`'s sandbox retry.""" + """Launch one Chromium session without silently changing its sandbox mode.""" exe = find_browser() if exe is None: raise RuntimeError( @@ -1041,14 +1030,9 @@ def _browser_session(*, gl: str, sandbox: bool) -> "Any": "or install a supported browser. Native export (engine=Engine.default) " "and HTML export need nothing extra." ) - from ._chromium import ChromiumError, ChromiumSession + from ._chromium import ChromiumSession - try: - return ChromiumSession(exe, gl=gl, sandbox=sandbox) - except ChromiumError: - if not sandbox: - raise - return ChromiumSession(exe, gl=gl, sandbox=False) + return ChromiumSession(exe, gl=gl, sandbox=sandbox) def _native_image( diff --git a/scripts/verify_dependency_lock_inventory.py b/scripts/verify_dependency_lock_inventory.py new file mode 100644 index 00000000..703d75d1 --- /dev/null +++ b/scripts/verify_dependency_lock_inventory.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Keep the dependency-audit lockfile inventory explicit and complete.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EXPECTED_LOCKFILES = frozenset( + { + "Cargo.lock", + "examples/osm/osmium-rs/Cargo.lock", + "package-lock.json", + "uv.lock", + "docs/app/uv.lock", + "docs/app/reflex.lock/bun.lock", + "benchmarks/requirements-ci.lock", + } +) +EXCLUDED_DIRECTORIES = frozenset({".git", ".venv", "node_modules", "target", "launch_baselines"}) + + +def _is_lockfile(path: Path) -> bool: + return path.name in {"Cargo.lock", "package-lock.json", "bun.lock", "uv.lock"} or ( + path.name.startswith("requirements") and path.name.endswith(".lock") + ) + + +def find_dependency_lockfiles(root: Path = ROOT) -> frozenset[str]: + """Return tracked dependency lockfiles, excluding generated environments.""" + paths = { + path.relative_to(root).as_posix() + for path in root.rglob("*") + if path.is_file() + and _is_lockfile(path) + and not any(part in EXCLUDED_DIRECTORIES for part in path.relative_to(root).parts) + } + return frozenset(paths) + + +def main() -> int: + actual = find_dependency_lockfiles() + missing = sorted(EXPECTED_LOCKFILES - actual) + unexpected = sorted(actual - EXPECTED_LOCKFILES) + if missing or unexpected: + if missing: + print(f"missing expected dependency lockfiles: {missing}", file=sys.stderr) + if unexpected: + print( + "dependency lockfiles missing from the audit inventory: " + f"{unexpected}", + file=sys.stderr, + ) + return 1 + print(f"dependency lockfile inventory OK ({len(actual)} files)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_local.py b/scripts/verify_local.py index 97f67453..b1b6b393 100644 --- a/scripts/verify_local.py +++ b/scripts/verify_local.py @@ -150,6 +150,11 @@ def _base_checks( ), requires_modules=("pytest",), ), + Check( + "dependency_lock_inventory", + "committed dependency lockfiles are covered by security audits", + (py, "scripts/verify_dependency_lock_inventory.py"), + ), Check( "error_safety", "public error messages, LOD boundaries, and mutation-safety tests", diff --git a/spec/process/production-readiness.md b/spec/process/production-readiness.md index 0a2f00e7..e0aed741 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -133,7 +133,8 @@ reports, and sharing a single file, but it has a clear security contract: launching Chromium so bad user input produces actionable Python errors, and keeps Chromium's sandbox enabled by default. Pass `sandbox=False` only for trusted HTML in constrained CI/container environments that cannot launch a - sandboxed browser. + sandboxed browser. A failed sandboxed launch does not silently downgrade to + an unsandboxed browser. - Export tests should include weird strings with ``, HTML entities, mixed-case tags, and Unicode line/paragraph separators. diff --git a/spec/process/security-audit-2026-07-06.md b/spec/process/security-audit-2026-07-06.md index f232a2c5..52aa9cf8 100644 --- a/spec/process/security-audit-2026-07-06.md +++ b/spec/process/security-audit-2026-07-06.md @@ -273,3 +273,13 @@ sandbox cannot initialize. Container/worker isolation is therefore the load- bearing control, not the sandbox flag. Follow-up pending (same item as XY-SEC-2026-03): make the fallback opt-in, or at minimum warn on the downgrade, so a sandbox loss is observable. + +#### Follow-up status as of 2026-08-05 + +The export contract is now fail-closed: `html_to_png` and persistent browser +sessions never retry a failed sandboxed launch with `--no-sandbox`. Callers +must pass `sandbox=False` explicitly when they accept an unsandboxed browser. +The repository also has a scheduled dependency-audit workflow covering the +root and docs Python environments, Rust locks, npm, Bun, and the benchmark +requirements lock. `scripts/verify_dependency_lock_inventory.py` fails when a +new committed dependency lock is not added to that audit inventory. diff --git a/tests/test_figure.py b/tests/test_figure.py index 0149b122..a4ef13f9 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -2011,7 +2011,7 @@ def fake_run(args, **kwargs): assert "--no-sandbox" in seen[1] -def test_html_to_png_retries_without_sandbox_when_browser_crashes(monkeypatch): +def test_html_to_png_fails_closed_when_sandboxed_browser_crashes(monkeypatch): from xy import export seen = [] @@ -2021,21 +2021,15 @@ def test_html_to_png_retries_without_sandbox_when_browser_crashes(monkeypatch): def fake_run(args, **kwargs): del kwargs seen.append(args) - if len(seen) == 2: - shot = next( - arg.removeprefix("--screenshot=") for arg in args if arg.startswith("--screenshot=") - ) - Path(shot).write_bytes(b"\x89PNG\r\n\x1a\nfake") - return export_module.subprocess.CompletedProcess(args, 0, stdout="", stderr="") return export_module.subprocess.CompletedProcess(args, -6, stdout="", stderr="crashed") monkeypatch.setattr(export.subprocess, "run", fake_run) - data = export.html_to_png("", 320, 200) + with pytest.raises(RuntimeError, match="sandboxed launch produced no screenshot"): + export.html_to_png("", 320, 200) - assert data == b"\x89PNG\r\n\x1a\nfake" + assert len(seen) == 1 assert "--no-sandbox" not in seen[0] - assert "--no-sandbox" in seen[1] # --------------------------------------------------------------------------- diff --git a/tests/test_image_export.py b/tests/test_image_export.py index e61776a3..f3a10787 100644 --- a/tests/test_image_export.py +++ b/tests/test_image_export.py @@ -33,6 +33,25 @@ def _pil(): return pytest.importorskip("PIL.Image") +def test_browser_session_does_not_retry_without_sandbox(monkeypatch): + from xy import _chromium + + calls = [] + + class FailingSession: + def __init__(self, executable, *, gl, sandbox): + calls.append((executable, gl, sandbox)) + raise _chromium.ChromiumError("sandbox unavailable") + + monkeypatch.setattr(export, "find_browser", lambda explicit=None: "/fake/chrome") + monkeypatch.setattr(_chromium, "ChromiumSession", FailingSession) + + with pytest.raises(_chromium.ChromiumError, match="sandbox unavailable"): + export._browser_session(gl="software", sandbox=True) + + assert calls == [("/fake/chrome", "software", True)] + + def _decode(data: bytes): image = _pil().open(io.BytesIO(data)) image.load() From 4dee3fc5e2ee347621fe58d19f7816d77366f0d2 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 5 Aug 2026 14:04:56 +0530 Subject: [PATCH 2/3] Harden dependency audit workflow --- .github/workflows/security-audit.yml | 8 ++++++ scripts/verify_dependency_lock_inventory.py | 26 +++++++++++------- spec/api/export.md | 30 ++++++++------------- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index f2579ac0..304a8dbd 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -11,6 +11,13 @@ on: - "**/uv.lock" - "**/requirements*.lock" - "pyproject.toml" + - "Cargo.toml" + - "**/Cargo.toml" + - "package.json" + - "**/package.json" + - "docs/app/pyproject.toml" + - "docs/app/reflex.lock/package.json" + - "benchmarks/requirements*.txt" - "scripts/verify_dependency_lock_inventory.py" schedule: - cron: "17 3 * * 1" @@ -27,6 +34,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 + persist-credentials: false - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: diff --git a/scripts/verify_dependency_lock_inventory.py b/scripts/verify_dependency_lock_inventory.py index 703d75d1..ed49da47 100644 --- a/scripts/verify_dependency_lock_inventory.py +++ b/scripts/verify_dependency_lock_inventory.py @@ -3,6 +3,7 @@ from __future__ import annotations +import subprocess import sys from pathlib import Path @@ -18,7 +19,7 @@ "benchmarks/requirements-ci.lock", } ) -EXCLUDED_DIRECTORIES = frozenset({".git", ".venv", "node_modules", "target", "launch_baselines"}) +EXCLUDED_PATH_PARTS = frozenset({"launch_baselines"}) def _is_lockfile(path: Path) -> bool: @@ -28,15 +29,20 @@ def _is_lockfile(path: Path) -> bool: def find_dependency_lockfiles(root: Path = ROOT) -> frozenset[str]: - """Return tracked dependency lockfiles, excluding generated environments.""" - paths = { - path.relative_to(root).as_posix() - for path in root.rglob("*") - if path.is_file() - and _is_lockfile(path) - and not any(part in EXCLUDED_DIRECTORIES for part in path.relative_to(root).parts) - } - return frozenset(paths) + """Return committed dependency lockfiles, excluding local generated files.""" + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + check=True, + capture_output=True, + ) + return frozenset( + path + for raw_path in result.stdout.split(b"\0") + if raw_path + for path in (raw_path.decode("utf-8"),) + if not any(part in EXCLUDED_PATH_PARTS for part in Path(path).parts) + if _is_lockfile(Path(path)) + ) def main() -> int: diff --git a/spec/api/export.md b/spec/api/export.md index 259e403d..ee33ece8 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -187,25 +187,17 @@ end-to-end parity: the two download paths can disagree on the same chart. Closing this requires a theme snapshot on the comm channel and a bump of the message contract; nothing in the current protocol carries it. -## 7. The `--no-sandbox` auto-fallback - -Chromium launches sandboxed by default. Both browser paths silently downgrade on -failure: - -- `html_to_png` (`export.py:509-526`): if the sandboxed run produces no - screenshot, it rebuilds the argv with `--no-sandbox` inserted and re-runs - before raising. The final error reports both attempts. -- `_browser_session` (`export.py:926-931`): retries - `ChromiumSession(..., sandbox=False)` on `ChromiumError`. - -So `--no-sandbox` can appear without the caller requesting it, on input that -`html_to_png` accepts as arbitrary HTML. This is a known, accepted residual risk -taken to keep CI and container rasterization working where the sandbox cannot -initialize — see [XY-SEC-2026-03 and its 2026-07-20 status -note](../process/security-audit-2026-07-06.md#status-as-of-2026-07-20-xy-sec-2026-03). The -pending follow-up is to make the fallback opt-in, or at minimum warn, so a -sandbox loss is observable. `sandbox=False` remains the explicit escape hatch -for trusted HTML. +## 7. Chromium sandbox contract + +Chromium launches sandboxed by default. If the sandboxed launch fails, +`html_to_png` and persistent browser sessions fail closed; they never retry with +`--no-sandbox`. This keeps the public contract truthful for arbitrary HTML and +prevents an implicit security downgrade. + +`sandbox=False` remains the explicit escape hatch for trusted HTML in constrained +CI or container environments that cannot launch a sandboxed browser. The +security rationale and historical audit are recorded in +[XY-SEC-2026-03](../process/security-audit-2026-07-06.md#status-as-of-2026-07-20-xy-sec-2026-03). ## 8. Batch export From a07c3d6f8110fc121ff7d204edea96646b710147 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 5 Aug 2026 14:13:44 +0530 Subject: [PATCH 3/3] Harden lock inventory error handling --- scripts/verify_dependency_lock_inventory.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/verify_dependency_lock_inventory.py b/scripts/verify_dependency_lock_inventory.py index ed49da47..d024b799 100644 --- a/scripts/verify_dependency_lock_inventory.py +++ b/scripts/verify_dependency_lock_inventory.py @@ -30,23 +30,30 @@ def _is_lockfile(path: Path) -> bool: def find_dependency_lockfiles(root: Path = ROOT) -> frozenset[str]: """Return committed dependency lockfiles, excluding local generated files.""" - result = subprocess.run( - ["git", "-C", str(root), "ls-files", "-z"], - check=True, - capture_output=True, - ) + try: + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + check=True, + capture_output=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise RuntimeError("dependency lock inventory requires a git checkout and git") from exc return frozenset( path for raw_path in result.stdout.split(b"\0") if raw_path - for path in (raw_path.decode("utf-8"),) + for path in (raw_path.decode("utf-8", "surrogateescape"),) if not any(part in EXCLUDED_PATH_PARTS for part in Path(path).parts) if _is_lockfile(Path(path)) ) def main() -> int: - actual = find_dependency_lockfiles() + try: + actual = find_dependency_lockfiles() + except RuntimeError as exc: + print(f"dependency lockfile inventory failed: {exc}", file=sys.stderr) + return 1 missing = sorted(EXPECTED_LOCKFILES - actual) unexpected = sorted(actual - EXPECTED_LOCKFILES) if missing or unexpected: