From 9d067724a4759d3e37331bfcfa276e999fa9d8ed Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 1 Aug 2026 14:46:43 -0400 Subject: [PATCH] GC Unit A: harden secret boundary regression checks --- .gitignore | 2 - AGENTS.md | 2 +- Cargo.toml | 1 - build.rs | 38 +----- justfile | 4 + src/main.rs | 2 - tools/check-public-env.py | 278 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 288 insertions(+), 39 deletions(-) create mode 100644 tools/check-public-env.py diff --git a/.gitignore b/.gitignore index e71a2dd..02275c9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,6 @@ node_modules/ .scratch # Runtime coordination state for the event-sourced GC executor. /.current-gc/ -# Generated by build.rs from POLYMODEL_* env vars (WASM bundle config). -/src/env.rs # Generated renderer worker artifacts (built by `just build-renderer-worker`). # The loader JS is hand-authored and committed; the .js and .wasm are generated. /public/renderer_worker.js diff --git a/AGENTS.md b/AGENTS.md index e96274f..d26bb77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ just serve Before review or handoff, run at least `just fix` and all relevant tests. Use `just test-all` for substantial changes. -In a fresh checkout or jj workspace, run `just check` once before `just fix`: `src/env.rs` is generated by `build.rs` from `POLYMODEL_*`, and `cargo fmt` fails to resolve the `env` module until a build has produced it. +In a fresh checkout or jj workspace, run `just check` once before `just fix` so the first compilation catches toolchain and dependency issues before formatting. ## Stack notes diff --git a/Cargo.toml b/Cargo.toml index aa40254..a07ef9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -174,7 +174,6 @@ tracing-subscriber = { version = "0.3", default-features = false, features = [ ] } [build-dependencies] -dotenvy = "0.15" serde_json = "1.0" [profile.web-release] diff --git a/build.rs b/build.rs index ffddcac..fc04f0f 100644 --- a/build.rs +++ b/build.rs @@ -1,12 +1,10 @@ //! Build script. //! -//! Reads `.env` and bakes any `POLYMODEL_*` variables into `src/env.rs` as -//! compile-time constants. This is how configuration reaches the WASM bundle, -//! which has no filesystem or process environment to read at runtime. -//! -//! Server-side runtime configuration (DATABASE_URL, HYDRANT_*) is NOT handled -//! here: the server binary loads `.env` directly via `dotenvy` and reads it into -//! a typed `ServerConfig` struct (see `src/indexing/config.rs`). +//! Generates the indexed record collection list from the authored lexicons. +//! Server-side runtime configuration is loaded by the server binary via +//! `dotenvy` and read into a typed `ServerConfig` struct (see +//! `src/indexing/config.rs`); no environment values are embedded in frontend +//! artifacts here. use serde_json::Value; use std::env; @@ -15,32 +13,6 @@ use std::io::Write; use std::path::{Path, PathBuf}; fn main() { - println!("cargo:rerun-if-changed=.env"); - - dotenvy::dotenv().ok(); - - let mut f = File::create("./src/env.rs").expect("create src/env.rs"); - f.write_all( - b"// This file is automatically generated by build.rs.\n\ - // Frontend (WASM) compile-time configuration.\n\ - // Do not edit; regenerate by rebuilding.\n", - ) - .expect("write env.rs header"); - - // Bake every POLYMODEL_* env var into the bundle as a `&'static str` const. - for (key, value) in env::vars() { - if let Some(rest) = key.strip_prefix("POLYMODEL_") { - if rest.is_empty() { - continue; - } - let line = format!( - "#[allow(unused)]\npub const {}: &str = \"{}\";\n", - key, - value.replace('\\', "\\\\").replace('"', "\\\"") - ); - f.write_all(line.as_bytes()).expect("write env.rs const"); - } - } generate_indexed_collections(); } fn generate_indexed_collections() { diff --git a/justfile b/justfile index d3f58bb..dab0258 100644 --- a/justfile +++ b/justfile @@ -109,6 +109,10 @@ verify-renderer-split: build-web # Regenerate crates/polymodel-api from authored lexicons via the local Jacquard checkout. generate-api: nix run ../jacquard +# Check that runtime OAuth secrets stay out of frontend build artifacts. +check-public-env *ARGS: + python3 tools/check-public-env.py {{ ARGS }} + # Validate portable event-sourced GC coordination state. validate-gc-state *ARGS: python3 tools/validate-gc-state.py {{ ARGS }} diff --git a/src/main.rs b/src/main.rs index c621804..527b2cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,8 +23,6 @@ mod thing_routes; mod viewer; mod viewer_route; -// Frontend compile-time configuration, generated by build.rs from POLYMODEL_*. -mod env; // Server-only indexing infrastructure (Hydrant firehose + SQLite projection). #[cfg(any(feature = "server", test))] mod indexing; diff --git a/tools/check-public-env.py b/tools/check-public-env.py new file mode 100644 index 0000000..8ba99ca --- /dev/null +++ b/tools/check-public-env.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Check that runtime OAuth secrets never cross the frontend build boundary.""" +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Callable, Iterable + +SECRET_NAMES = ( + "POLYMODEL_OAUTH_KEYSET_B64", + "POLYMODEL_COOKIE_KEY_B64", +) +SENTINELS = ( + "gc-unit-a-oauth-keyset-sentinel", + "gc-unit-a-cookie-key-sentinel", +) +APP_OUTPUT = Path("target/dx/polymodel/debug/web/public") +WORKER_FILES = (Path("public/renderer_worker.js"), Path("public/renderer_worker_bg.wasm")) + + +def fail(message: str) -> None: + raise ValueError(message) + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except UnicodeDecodeError: + return path.read_bytes().decode("utf-8", errors="ignore") + + +def assert_absent(paths: Iterable[Path], needles: Iterable[str]) -> None: + paths = list(paths) + needles = tuple(needles) + for path in paths: + data = path.read_bytes() + for needle in needles: + if needle.encode() in data: + fail(f"forbidden frontend secret material in {path}") + + +def blanket_frontend_environment_export_patterns(source: str) -> list[str]: + patterns = ( + r'File::create\s*\(\s*["\'](?:\./)?src/env\.rs', + r'(?:strip_prefix|starts_with)\s*\(\s*["\']POLYMODEL_', + r'env\s*::\s*vars\s*\(', + r'env\s*::\s*vars_os\s*\(', + r'cargo\s*:\s*rustc-env\s*=', + ) + return [pattern for pattern in patterns if re.search(pattern, source)] + + +def generated_frontend_env_surface_is_absent(root: Path) -> None: + if (root / "src/env.rs").exists(): + fail("src/env.rs exists; frontend environment generation was not removed") + build = read_text(root / "build.rs") + matches = blanket_frontend_environment_export_patterns(build) + if matches: + fail("build.rs still contains a blanket frontend environment export") + + +def oauth_secret_names_are_not_frontend_config(root: Path) -> None: + build = read_text(root / "build.rs") + main = read_text(root / "src/main.rs") + if "mod env" in main or "POLYMODEL_*" in build: + fail("frontend configuration module or wildcard export remains") + if blanket_frontend_environment_export_patterns(build): + fail("build.rs still contains a blanket frontend environment export") + if any(name in build for name in SECRET_NAMES): + fail("OAuth secret name appears in build.rs") + + +def oauth_runtime_secret_reads_remain_server_only(root: Path) -> None: + oauth = read_text(root / "src/oauth/mod.rs") + required = ( + "Option", + "ClientData::new(keyset, metadata)", + "env_get(\"POLYMODEL_OAUTH_KEYSET_B64\")", + "env_get(\"POLYMODEL_COOKIE_KEY_B64\")", + "struct EnvSource", + ) + missing = [needle for needle in required if needle not in oauth] + if missing: + fail("OAuth runtime secret/public-mode seam is missing: " + ", ".join(missing)) + + +def scan_frontend_artifacts(root: Path) -> None: + output = root / APP_OUTPUT + all_artifacts = sorted( + path for path in output.rglob("*") if path.is_file() and path.suffix in {".js", ".wasm"} + ) if output.is_dir() else [] + worker_names = {path.name for path in WORKER_FILES} + app_artifacts = [path for path in all_artifacts if path.name not in worker_names] + worker_paths = [root / path for path in WORKER_FILES] + if not app_artifacts: + fail(f"no real app .js/.wasm artifacts found under {output}") + if not any(path.suffix == ".js" for path in app_artifacts) or not any( + path.suffix == ".wasm" for path in app_artifacts + ): + fail("real app artifact set must include both .js and .wasm files") + missing_workers = [str(path) for path in worker_paths if not path.is_file()] + if missing_workers: + fail("missing renderer worker artifacts: " + ", ".join(missing_workers)) + assert_absent([*all_artifacts, *worker_paths], (*SECRET_NAMES, *SENTINELS)) + + +def copy_isolated_projects(root: Path, directory: Path) -> Path: + """Copy the app and its ../jacquard sibling into the expected workspace layout.""" + projects = directory / "Projects" + projects.mkdir() + checkout = projects / root.name + jacquard = root.parent / "jacquard" + if not jacquard.is_dir(): + fail(f"required sibling dependency is missing: {jacquard}") + ignore = shutil.ignore_patterns("target", ".jj", ".git", "src/env.rs") + shutil.copytree(root, checkout, ignore=ignore) + dependency_checkout = projects / "jacquard" + shutil.copytree(jacquard, dependency_checkout, ignore=ignore) + if not dependency_checkout.is_dir(): + fail(f"isolated sibling dependency was not copied: {dependency_checkout}") + return checkout + + +def run_clean_frontend_build(root: Path) -> None: + with tempfile.TemporaryDirectory(prefix="polymodel-public-env-") as directory: + checkout = copy_isolated_projects(root, Path(directory)) + stale = checkout / "src/env.rs" + if stale.exists(): + stale.unlink() + env = os.environ.copy() + env.update({SECRET_NAMES[0]: SENTINELS[0], SECRET_NAMES[1]: SENTINELS[1]}) + env["CARGO_TERM_COLOR"] = "never" + result = subprocess.run( + ["just", "build-web"], + cwd=checkout, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if result.returncode: + print("isolated frontend build failed", file=sys.stderr) + print("build output suppressed to avoid leaking secret sentinels", file=sys.stderr) + raise RuntimeError(f"just build-web exited with {result.returncode}") + generated_frontend_env_surface_is_absent(checkout) + scan_frontend_artifacts(checkout) + + +def self_test() -> None: + with tempfile.TemporaryDirectory(prefix="polymodel-public-env-self-test-") as directory: + root = Path(directory) + (root / "src").mkdir() + (root / "src/main.rs").write_text("fn main() {}\n", encoding="utf-8") + (root / "src/oauth").mkdir() + (root / "src/oauth/mod.rs").write_text( + "fn provision() -> Option { ClientData::new(keyset, metadata); " + "env_get(\"POLYMODEL_OAUTH_KEYSET_B64\"); " + "env_get(\"POLYMODEL_COOKIE_KEY_B64\"); struct EnvSource; }\n", + encoding="utf-8", + ) + build = root / "build.rs" + build.write_text( + "fn main() { let _ = std::env::var(\"OUT_DIR\"); }\n", encoding="utf-8" + ) + generated_frontend_env_surface_is_absent(root) + oauth_secret_names_are_not_frontend_config(root) + oauth_runtime_secret_reads_remain_server_only(root) + + def expect_failure(label: str, callback: Callable[[], None]) -> None: + try: + callback() + except ValueError: + return + fail(f"negative {label} fixture unexpectedly passed") + + (root / "src/env.rs").write_text("stale\n", encoding="utf-8") + expect_failure("stale-env", lambda: generated_frontend_env_surface_is_absent(root)) + (root / "src/env.rs").unlink() + + for label, source in ( + ("legacy-wildcard", 'File::create("./src/env.rs")'), + ("prefix-wildcard", 'let _ = key.strip_prefix("POLYMODEL_");'), + ("environment-iteration", "for (key, value) in std::env::vars() { use_it(key, value); }"), + ("os-environment-iteration", "for (key, value) in std::env::vars_os() { use_it(key, value); }"), + ("cargo-rustc-env", 'println!("cargo:rustc-env=POLYMODEL_FOO={value}");'), + ): + build.write_text(source, encoding="utf-8") + expect_failure(label, lambda: generated_frontend_env_surface_is_absent(root)) + build.write_text( + "fn main() { let _ = std::env::var(\"OUT_DIR\"); }\n", encoding="utf-8" + ) + generated_frontend_env_surface_is_absent(root) + + output = root / APP_OUTPUT + workers = [root / path for path in WORKER_FILES] + + def write_fixture( + app_files: dict[str, bytes], worker_files: dict[str, bytes] | None = None + ) -> None: + shutil.rmtree(output, ignore_errors=True) + shutil.rmtree(root / "public", ignore_errors=True) + output.mkdir(parents=True) + for relative, data in app_files.items(): + path = output / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + for path in workers: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes((worker_files or {}).get(path.name, b"clean worker")) + + complete_app = { + "polymodel.js": b"clean app javascript", + "polymodel_bg.wasm": b"clean app wasm", + "nested/chunk.js": b"clean nested javascript", + "nested/chunk.wasm": b"clean nested wasm", + } + write_fixture(complete_app) + scan_frontend_artifacts(root) + + write_fixture({"renderer_worker.js": b"worker-only", "renderer_worker_bg.wasm": b"worker-only"}) + expect_failure("worker-only artifact", lambda: scan_frontend_artifacts(root)) + write_fixture({}) + expect_failure("empty artifact", lambda: scan_frontend_artifacts(root)) + write_fixture({"polymodel.js": b"incomplete"}) + expect_failure("incomplete artifact", lambda: scan_frontend_artifacts(root)) + write_fixture(complete_app, {"renderer_worker.js": b"missing worker"}) + (root / "public/renderer_worker_bg.wasm").unlink() + expect_failure("missing worker", lambda: scan_frontend_artifacts(root)) + + for needle in (*SECRET_NAMES, *SENTINELS): + write_fixture({"polymodel.js": needle.encode(), "polymodel_bg.wasm": b"clean"}) + expect_failure("secret-bearing app artifact", lambda: scan_frontend_artifacts(root)) + write_fixture(complete_app, {"renderer_worker.js": SENTINELS[0].encode()}) + expect_failure("secret-bearing worker artifact", lambda: scan_frontend_artifacts(root)) + print("check-public-env self-test passed") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--self-test", action="store_true") + parser.add_argument("--skip-build", action="store_true", help="run source checks without building") + parser.add_argument("checks", nargs="*", help="named checks to run") + args = parser.parse_args() + checks = { + "generated_frontend_env_surface_is_absent": generated_frontend_env_surface_is_absent, + "oauth_secret_names_are_not_frontend_config": oauth_secret_names_are_not_frontend_config, + "oauth_runtime_secret_reads_remain_server_only": oauth_runtime_secret_reads_remain_server_only, + } + try: + if args.self_test: + self_test() + return 0 + selected = args.checks or list(checks) + unknown = [name for name in selected if name not in checks] + if unknown: + fail("unknown check(s): " + ", ".join(unknown)) + root = Path.cwd() + for name in selected: + checks[name](root) + print(f"PASS {name}") + if not args.skip_build: + run_clean_frontend_build(root) + print("PASS isolated_clean_frontend_build") + except (OSError, RuntimeError, ValueError) as exc: + print(f"check-public-env failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) -- knotmirror 0.0.1