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
76 changes: 76 additions & 0 deletions .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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"
Comment thread
harsh21234i marked this conversation as resolved.
- "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"
workflow_dispatch:

permissions:
contents: read

jobs:
audit:
name: Audit committed dependency locks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment thread
harsh21234i marked this conversation as resolved.
with:
fetch-depth: 1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
persist-credentials: false
- 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

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
file=".github/workflows/security-audit.yml"
printf '%s\n' '--- workflow excerpt ---'
sed -n '1,110p' "$file"
printf '%s\n' '--- related workflow references ---'
rg -n -C 2 'bun|npm install|audit|workflow_dispatch|pull_request|push|permissions|runs-on' .github/workflows "$file" 2>/dev/null || true

Repository: reflex-dev/xy

Length of output: 43325


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
import json, urllib.request
url = "https://registry.npmjs.org/bun/1.2.20"
with urllib.request.urlopen(url, timeout=15) as r:
    data = json.load(r)
print("name:", data.get("name"))
print("version:", data.get("version"))
print("scripts:", json.dumps(data.get("scripts", {}), sort_keys=True))
print("dist:", json.dumps({
    k: data.get("dist", {}).get(k)
    for k in ("tarball", "integrity", "shasum")
}, sort_keys=True))
PY

Repository: reflex-dev/xy

Length of output: 2437


🌐 Web query:

npm bun 1.2.20 package.json scripts postinstall dist integrity

💡 Result:

In Bun, including version 1.2.20, the handling of package.json scripts like postinstall is distinct from npm due to a "default-secure" security model [1][2][3]. 1. Default Security Behavior: Bun does not execute arbitrary lifecycle scripts (such as postinstall, preinstall, or prepare) for installed dependencies by default [1][2][3]. This is done to mitigate the security risks associated with running unknown code during package installation [1][2][3]. 2. Opt-in via trustedDependencies: To run lifecycle scripts for specific packages, you must explicitly allow them by adding the package name to the trustedDependencies array in your root package.json [1][2][3]. Once added, Bun will execute the lifecycle scripts for those packages upon installation [1][2][3]. 3. Project-Level Scripts: Bun does execute lifecycle scripts defined in your own project's package.json at the appropriate time when you run commands like bun install [1][4]. 4. Integrity and dist: Bun verifies the integrity of downloaded packages [1][4]. Recent updates, including those relevant to the 1.2.x series, have improved how Bun handles and stores integrity hashes (such as sha512) in the lockfile for various dependency types, including remote tarballs, to ensure consistency and security [5]. 5. Dist/Optimization: Bun automatically optimizes postinstall scripts for certain popular packages (e.g., esbuild, sharp) by determining if they are necessary, though this behavior can be configured or disabled using specific environment variables like BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER [1][4]. For your specific scenario, if you are expecting a postinstall script to run for a dependency and it is not, ensure the package is listed in your package.json's trustedDependencies [1][2]. Integrity verification is handled automatically by Bun and is recorded in the lockfile to maintain security [1][4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
json="$(mktemp)"
trap 'rm -f "$json"' EXIT
curl -fsSLk --max-time 20 'https://registry.npmjs.org/bun/1.2.20' -o "$json"
python3 - "$json" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
    data = json.load(f)
print("name:", data.get("name"))
print("version:", data.get("version"))
print("scripts:", json.dumps(data.get("scripts", {}), sort_keys=True))
print("dist:", json.dumps({
    k: data.get("dist", {}).get(k)
    for k in ("tarball", "integrity", "shasum")
}, sort_keys=True))
PY

Repository: reflex-dev/xy

Length of output: 451


🏁 Script executed:

#!/bin/bash
set -eu
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl -fsSLk --max-time 30 'https://registry.npmjs.org/bun/-/bun-1.2.20.tgz' -o "$tmp/bun.tgz"
tar -tzf "$tmp/bun.tgz" | sed -n '1,80p'
tar -xzf "$tmp/bun.tgz" -C "$tmp"
printf '%s\n' '--- package manifest ---'
cat "$tmp/package/package.json"
printf '%s\n' '--- install.js (first 240 lines) ---'
sed -n '1,240p' "$tmp/package/install.js"

Repository: reflex-dev/xy

Length of output: 9695


🏁 Script executed:

#!/bin/bash
set -eu
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl -fsSLk --max-time 30 'https://registry.npmjs.org/bun/-/bun-1.2.20.tgz' -o "$tmp/bun.tgz"
tar -xzf "$tmp/bun.tgz" -C "$tmp"
script="$tmp/package/install.js"
printf '%s\n' '--- installer security-relevant references ---'
rg -n -i 'sha|hash|checksum|integrity|download|fetch|exec|spawn|child_process|https?://|optional|binary|rename|chmod|writeFile|bun' "$script"
printf '%s\n' '--- installer tail ---'
tail -n 180 "$script"

Repository: reflex-dev/xy

Length of output: 10718


Security Misconfiguration (CWE-494): Download of Code Without Integrity Check

Reachability: External · Exploitability: Difficult

Use an integrity-pinned Bun bootstrap.

npm install --global bun@1.2.20 runs the package’s postinstall script. That script installs or downloads the platform Bun binary without an independently pinned checksum. Use a commit-SHA-pinned setup action or verify an official release checksum before execution.

🧰 Tools
🪛 zizmor (1.29.0)

[warning] 65-65: ad-hoc installation of packages (adhoc-packages): installs a package outside of a lockfile

(adhoc-packages)

🤖 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 @.github/workflows/security-audit.yml at line 65, Replace the unpinned
npm-based Bun installation in the security-audit workflow with an
integrity-pinned bootstrap: use a setup action pinned to a commit SHA, or
download the official Bun release and verify its checksum before execution.
Preserve the required Bun version while ensuring the installed binary is
independently authenticated.

Source: Linters/SAST tools

cd docs/app/reflex.lock
bun install --frozen-lockfile
bun audit --audit-level=high
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
32 changes: 8 additions & 24 deletions python/xy/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Comment thread
harsh21234i marked this conversation as resolved.


def _native_image(
Expand Down
74 changes: 74 additions & 0 deletions scripts/verify_dependency_lock_inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Keep the dependency-audit lockfile inventory explicit and complete."""

from __future__ import annotations

import subprocess
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_PATH_PARTS = frozenset({"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 committed dependency lockfiles, excluding local generated files."""
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", "surrogateescape"),)
if not any(part in EXCLUDED_PATH_PARTS for part in Path(path).parts)
if _is_lockfile(Path(path))
)


def main() -> int:
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:
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())
5 changes: 5 additions & 0 deletions scripts/verify_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 11 additions & 19 deletions spec/api/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion spec/process/production-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `</script>`, HTML entities,
mixed-case tags, and Unicode line/paragraph separators.

Expand Down
10 changes: 10 additions & 0 deletions spec/process/security-audit-2026-07-06.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 4 additions & 10 deletions tests/test_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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("<!doctype html>", 320, 200)
with pytest.raises(RuntimeError, match="sandboxed launch produced no screenshot"):
export.html_to_png("<!doctype html>", 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]


# ---------------------------------------------------------------------------
Expand Down
19 changes: 19 additions & 0 deletions tests/test_image_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down