security: gate release path leaks
Jaime Fournier
279d204442ebf0539f70918c1a6f99c12dd83c02
--- a/Makefile +++ b/Makefile @@ -608,6 +608,7 @@ release-evidence: $(MAKE) unification-release-check > "$(EVIDENCE_DIR)/unification-release-check.txt" 2>&1 $(MAKE) security-hardware-smoke > "$(EVIDENCE_DIR)/security-hardware-smoke.txt" 2>&1 $(MAKE) reproducibility-report > "$(EVIDENCE_DIR)/reproducibility-report.txt" 2>&1 + support/check-release-path-leaks.sh dist/jerboa > "$(EVIDENCE_DIR)/path-leaks.txt" 2>&1 JERBOA_RELEASE_DIR="$(RELEASE_DIR)" JERBOA_SIGNING_EVIDENCE_DIR="$(EVIDENCE_DIR)/signing" support/sign-release-artifacts.sh evidence > "$(EVIDENCE_DIR)/signing-evidence.txt" 2>&1 $(MAKE) sbom > "$(EVIDENCE_DIR)/sbom.txt" 2>&1 shasum -a 256 Dockerfile Makefile SECURITY.md README.md rust-toolchain.toml \ @@ -615,12 +616,13 @@ release-evidence: support/container-dependencies.lock support/container-inputs.sh \ support/install.sh support/ensure-jerboa.sh support/sign-release-artifacts.sh \ support/sbom.sh support/sanitize-evidence.sh support/fasl-cache-equivalence.ss \ - support/reproducibility-report.sh tools/security-audit.sh \ + support/check-release-path-leaks.sh support/reproducibility-report.sh tools/security-audit.sh \ > "$(EVIDENCE_DIR)/release-inputs-sha256.txt" rm -rf "$(EVIDENCE_DIR)/sbom" "$(EVIDENCE_DIR)/reproducibility" cp -R "$(SBOM_DIR)" "$(EVIDENCE_DIR)/sbom" cp -R "$(REPRO_DIR)" "$(EVIDENCE_DIR)/reproducibility" grep -q '^signature_status=' "$(EVIDENCE_DIR)/signing/status.txt" + grep -q '^path_leak_status=pass$$' "$(EVIDENCE_DIR)/path-leaks.txt" grep -q '^status=match$$' "$(EVIDENCE_DIR)/reproducibility/result.txt" grep -q '^sbom_status=present$$' "$(EVIDENCE_DIR)/sbom/manifest.txt" support/sanitize-evidence.sh "$(EVIDENCE_DIR)" --- a/docs/harden.md +++ b/docs/harden.md @@ -533,7 +533,12 @@ Boot files contain Scheme symbol names as strings. Even encrypted, they'll be vi ### Removing Build Paths -GCC and Chez embed source paths. Strip them: +GCC and Chez can embed source paths. The canonical release policy lives in +[`release-security.md`](release-security.md): release binary builders install +Chez source-path sanitizers, pass `-ffile-prefix-map=<repo>=.`, and +use same-length byte redaction for embedded boot/bundle payloads. The +`support/check-release-path-leaks.sh` gate checks both `dist/jerboa` and +packaged `.tar.gz` artifacts against `$HOME` / checkout path leaks. ```makefile # GCC: use -ffile-prefix-map to replace paths --- a/docs/kimi3-security-recommmendations.md +++ b/docs/kimi3-security-recommmendations.md @@ -779,9 +779,14 @@ they are build-time. Close the runtime loop. failures now log internal details server-side and return client-visible opaque refs; `(std net thread-httpd)` does the same for handler exceptions. `jerboa_security_scan` now flags raw `condition-message`/`display-condition` - values written into HTTP response constructors. Remaining work: router + values written into HTTP response constructors. Release binary builds now + suppress Chez source metadata, pass `-ffile-prefix-map=<repo>=.`, redact + embedded boot/bundle build-root byte sequences before C embedding, and gate + both `dist/jerboa` and packaged `.tar.gz` artifacts with + `support/check-release-path-leaks.sh`; the release evidence bundle records + `path-leaks.txt` and requires `path_leak_status=pass`. Remaining work: router integrations plus MCP/LSP/repl-protocol surfaces need opaque client error - refs, and release artifact path-leak checks still need a gate. + refs. ### K3-P1-11 — Parameterized-only SQL in the safe surface **Serves:** G1. **Effort:** 2–3 days. --- a/docs/release-security.md +++ b/docs/release-security.md @@ -139,6 +139,18 @@ release unification check, hardware-hardening smoke, signing evidence, SBOM, reproducibility report, release-input hashes, git state, and host-neutral build environment under `dist/release-evidence/`. The evidence target sanitizes and fails closed on private path, SSH remote, host, or `uname -a` markers. +It also runs `support/check-release-path-leaks.sh` against `dist/jerboa` and +requires `path_leak_status=pass` in `path-leaks.txt`. Release packaging runs +the same checker against the final `.tar.gz`, so shipped artifacts cannot +embed `$HOME`, the repository root, or the physical checkout path. The binary +builders install Chez source-path sanitizers (`current-make-source-object`, +`generate-inspector-information #f`, no procedure source info) and pass +`-ffile-prefix-map=<repo>=.` by default. Embedded Chez boot files, program +images, and bundle payloads are additionally processed by +`support/redact-build-paths.ss`, which replaces exact local build-root byte +sequences with same-length placeholder bytes before C array generation or +bundle hashing. Set `JERBOA_BINARY_PATH_MAP=0` only for local compiler +diagnostics. Current release artifacts must have SHA-256 files and detached publisher signatures. `make sign-release-artifacts` creates a canonical --- a/support/build-binary.sh +++ b/support/build-binary.sh @@ -215,6 +215,9 @@ if flag_enabled "${JERBOA_BINARY_HARDEN:-1}"; then HARDEN_CFLAGS=$(target_hardening_cflags "$TARGET_OS" "$MACHINE_TYPE") HARDEN_LDFLAGS=$(target_hardening_ldflags "$TARGET_OS" "$MACHINE_TYPE") fi +if flag_enabled "${JERBOA_BINARY_PATH_MAP:-1}"; then + HARDEN_CFLAGS="$HARDEN_CFLAGS -ffile-prefix-map=$JERBOA_HOME=." +fi # ── Find the Chez install dir (libkernel.a + scheme.h + boot files) ────────── CSV_DIR="" @@ -263,6 +266,13 @@ echo "" # ── Step 2: Embed boot files + program as C byte arrays ────────────────────── echo "==> [2/4] Embedding boot files as C arrays" +PETITE_BOOT="$OBJ_DIR/petite.boot" +SCHEME_BOOT="$OBJ_DIR/scheme.boot" +cp "$CSV_DIR/petite.boot" "$PETITE_BOOT" +cp "$CSV_DIR/scheme.boot" "$SCHEME_BOOT" +JERBOA_HOME="$JERBOA_HOME" "$SCHEME" --script "$JERBOA_HOME/support/redact-build-paths.ss" \ + "$PETITE_BOOT" "$SCHEME_BOOT" "$WPO_SO" + embed() { in="$1"; stem="$2" var="${stem}_data"; sz="${stem}_size"; out="$stem.h" @@ -275,8 +285,8 @@ embed() { printf '};\nstatic const unsigned int %s = sizeof(%s);\n' "$sz" "$var" >> "$out" } -embed "$CSV_DIR/petite.boot" petite_boot -embed "$CSV_DIR/scheme.boot" scheme_boot +embed "$PETITE_BOOT" petite_boot +embed "$SCHEME_BOOT" scheme_boot embed "$WPO_SO" program_boot # When baking a Typed Jerboa Rust staticlib, extract its jt_* exports and --- a/support/build-boot.ss +++ b/support/build-boot.ss @@ -14,6 +14,27 @@ (import (scheme)) +(define source-path-placeholder "<jerboa-release-source>") + +(define (sanitized-source-object sfd bfp efp) + (if (source-file-descriptor? sfd) + (make-source-object + (source-file-descriptor + source-path-placeholder + (source-file-descriptor-checksum sfd)) + bfp + efp) + #f)) + +(define (install-release-source-path-sanitizer!) + (current-make-source-object sanitized-source-object) + (debug-level 0) + (generate-inspector-information #f) + (generate-procedure-source-information #f) + (enable-error-source-expression #f)) + +(install-release-source-path-sanitizer!) + (define (string-suffix? str suffix) (let ([slen (string-length str)] [xlen (string-length suffix)]) --- a/support/build-jerboa-multicall.ss +++ b/support/build-jerboa-multicall.ss @@ -56,6 +56,18 @@ (lp (+ i 1)))) (display "'" out)))) +(define (shell-join paths) + (apply string-append + (map (lambda (path) (string-append " " (shell-quote path))) + paths))) + +(define (redact-build-paths! paths) + (run (format "JERBOA_HOME=~a ~a --script ~a/support/redact-build-paths.ss~a" + (shell-quote repo) + (shell-quote scheme-exe) + (shell-quote repo) + (shell-join paths)))) + (define (run cmd) (printf " ~a~n" cmd) (let ([rc (safe-system cmd)]) @@ -116,6 +128,25 @@ (gensym-prefix old-gensym-prefix) (gensym-count old-gensym-count))))) +(define source-path-placeholder "<jerboa-release-source>") + +(define (sanitized-source-object sfd bfp efp) + (if (source-file-descriptor? sfd) + (make-source-object + (source-file-descriptor + source-path-placeholder + (source-file-descriptor-checksum sfd)) + bfp + efp) + #f)) + +(define (install-release-source-path-sanitizer!) + (current-make-source-object sanitized-source-object) + (debug-level 0) + (generate-inspector-information #f) + (generate-procedure-source-information #f) + (enable-error-source-expression #f)) + (define entry-build-id 'build-deterministic) (define (entry-library-name name) @@ -439,6 +470,7 @@ (let ([dir (getenv "JERBOA_SEED_OBJECT_CACHE_DIR")]) (and dir (> (string-length dir) 0) dir))) (define project-version (read-project-version repo)) +(install-release-source-path-sanitizer!) ;; ── cross-compilation params (mirror support/build-jerbuild.sh) ─────────────── ;; TARGET_MACHINE set => cross build. JERBOA_CROSS_PREFIX is the install prefix ;; of the cross-built Chez (`.chez-cross-<mt>`, holding lib/csv*/<mt>/...) and @@ -1311,11 +1343,18 @@ " -Wl,-no_uuid" "")) -(define harden-cflags - (if (env-enabled? "JERBOA_BINARY_HARDEN" #t) - (target-hardening-cflags target-os machine) +(define path-map-cflags + (if (env-enabled? "JERBOA_BINARY_PATH_MAP" #t) + (format " -ffile-prefix-map=~a=." (shell-quote repo)) "")) +(define harden-cflags + (string-append + (if (env-enabled? "JERBOA_BINARY_HARDEN" #t) + (target-hardening-cflags target-os machine) + "") + path-map-cflags)) + (define harden-ldflags (string-append (if (env-enabled? "JERBOA_BINARY_HARDEN" #t) @@ -1662,6 +1701,7 @@ (run (format "rm -f ~a && cd ~a && find . -type f | sort | ~a -cf ~a -T -" (shell-quote bundle-tar) (shell-quote stage) bundle-tar-command (shell-quote bundle-tar))) +(redact-build-paths! (list bundle-tar)) (define sha-file (format "~a/bundle.sha256" build-dir)) (run (format "(shasum -a 256 ~a 2>/dev/null || sha256sum ~a) | awk '{print $1}' > ~a" @@ -1669,8 +1709,16 @@ (define bundle-sha (trim-ws (read-text-file sha-file))) (printf "==> [4/6] embed boot files + program + bundle as C arrays~n") -(file->c-header (format "~a/petite.boot" csv-dir) (format "~a/petite_boot.h" build-dir) "petite_boot_data" "petite_boot_size") -(file->c-header (format "~a/scheme.boot" csv-dir) (format "~a/scheme_boot.h" build-dir) "scheme_boot_data" "scheme_boot_size") +(define petite-boot (format "~a/petite.boot" build-dir)) +(define scheme-boot (format "~a/scheme.boot" build-dir)) +(run (format "cp ~a ~a && cp ~a ~a" + (shell-quote (format "~a/petite.boot" csv-dir)) + (shell-quote petite-boot) + (shell-quote (format "~a/scheme.boot" csv-dir)) + (shell-quote scheme-boot))) +(redact-build-paths! (list petite-boot scheme-boot program-image)) +(file->c-header petite-boot (format "~a/petite_boot.h" build-dir) "petite_boot_data" "petite_boot_size") +(file->c-header scheme-boot (format "~a/scheme_boot.h" build-dir) "scheme_boot_data" "scheme_boot_size") (file->c-header program-image (format "~a/program_boot.h" build-dir) "program_boot_data" "program_boot_size") (file->c-header bundle-tar (format "~a/bundle_tar.h" build-dir) "bundle_tar_data" "bundle_tar_size") (call-with-replacing-output-file (format "~a/bundle_meta.h" build-dir) --- a/support/build-jerbuild.sh +++ b/support/build-jerbuild.sh @@ -167,6 +167,9 @@ if flag_enabled "${JERBOA_BINARY_HARDEN:-1}"; then HARDEN_CFLAGS=$(target_hardening_cflags "$TARGET_OS" "$MACHINE_TYPE") HARDEN_LDFLAGS=$(target_hardening_ldflags "$TARGET_OS" "$MACHINE_TYPE") fi +if flag_enabled "${JERBOA_BINARY_PATH_MAP:-1}"; then + HARDEN_CFLAGS="$HARDEN_CFLAGS -ffile-prefix-map=$JERBOA_HOME=." +fi CSV_DIR="" for prefix in "$CSV_SEARCH_PREFIX" /usr/local/lib /usr/lib /usr/lib64 /opt/homebrew/lib /opt/local/lib; do @@ -250,6 +253,8 @@ for f in libkernel.a scheme.h petite.boot scheme.boot liblz4.a libz.a; do [ -f "$CSV_DIR/$f" ] && cp "$CSV_DIR/$f" "$BUNDLE_STAGE/csv/$MACHINE_TYPE/" done (cd "$BUNDLE_STAGE" && tar -rf "$BUNDLE_TAR" csv) +JERBOA_HOME="$JERBOA_HOME" "$SCHEME" --script "$JERBOA_HOME/support/redact-build-paths.ss" \ + "$WPO_SO" "$BUNDLE_TAR" BUNDLE_BYTES=$(wc -c < "$BUNDLE_TAR" | tr -d ' ') BUNDLE_SHA=$(shasum -a 256 "$BUNDLE_TAR" 2>/dev/null | awk '{print $1}' \ @@ -258,6 +263,13 @@ echo " bundle: $BUNDLE_BYTES bytes, sha256=${BUNDLE_SHA%????????????????????? echo "" echo "==> [3/5] Embed boot files + program + bundle as C arrays" +PETITE_BOOT="$OBJ_DIR/petite.boot" +SCHEME_BOOT="$OBJ_DIR/scheme.boot" +cp "$CSV_DIR/petite.boot" "$PETITE_BOOT" +cp "$CSV_DIR/scheme.boot" "$SCHEME_BOOT" +JERBOA_HOME="$JERBOA_HOME" "$SCHEME" --script "$JERBOA_HOME/support/redact-build-paths.ss" \ + "$PETITE_BOOT" "$SCHEME_BOOT" + embed() { in="$1"; stem="$2" var="${stem}_data"; sz="${stem}_size"; out="$stem.h" @@ -267,8 +279,8 @@ embed() { -e 's/^ *//;s/ *$//;s/ */ /g;s/ /,0x/g;s/^/0x/;s/$/,/' >> "$out" printf '};\nstatic const unsigned int %s = sizeof(%s);\n' "$sz" "$var" >> "$out" } -embed "$CSV_DIR/petite.boot" petite_boot -embed "$CSV_DIR/scheme.boot" scheme_boot +embed "$PETITE_BOOT" petite_boot +embed "$SCHEME_BOOT" scheme_boot embed "$WPO_SO" program_boot embed "$BUNDLE_TAR" bundle_tar new file mode 100755 --- /dev/null +++ b/support/check-release-path-leaks.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# Verify release binaries/artifacts do not embed host build paths. + +set -eu + +usage() { + echo "Usage: $0 ARTIFACT_OR_BINARY..." >&2 +} + +[ "$#" -gt 0 ] || { usage; exit 2; } + +repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +repo_physical=$(CDPATH= cd -- "$repo" && pwd -P) +home_path=${HOME:-} + +tmp_root= +cleanup() { + [ -z "$tmp_root" ] || rm -rf "$tmp_root" +} +trap cleanup EXIT HUP INT TERM + +make_tmp_root() { + if [ -z "$tmp_root" ]; then + tmp_parent=${TMPDIR:-/tmp} + tmp_root=$(mktemp -d "$tmp_parent/jerboa-path-leaks.XXXXXX") + fi +} + +scan_stream() { + label=$1 + pattern_name=$2 + pattern=$3 + data=$4 + + if [ -n "$pattern" ] && [ "$pattern" != "/" ] && grep -F -q -- "$pattern" "$data"; then + printf 'leak_status=fail artifact=%s pattern=%s\n' "$label" "$pattern_name" + return 1 + fi + return 0 +} + +scan_file() { + file=$1 + label=$2 + make_tmp_root + strings_out="$tmp_root/strings.$$" + : > "$strings_out" + strings -a "$file" > "$strings_out" 2>/dev/null || true + + failed=0 + scan_stream "$label" HOME "$home_path" "$strings_out" || failed=1 + scan_stream "$label" repo-root "$repo" "$strings_out" || failed=1 + if [ "$repo_physical" != "$repo" ]; then + scan_stream "$label" repo-root-physical "$repo_physical" "$strings_out" || failed=1 + fi + + rm -f "$strings_out" + return "$failed" +} + +scan_tar_gz() { + artifact=$1 + make_tmp_root + extract_dir="$tmp_root/extract.$$" + mkdir -p "$extract_dir" + tar -xzf "$artifact" -C "$extract_dir" + + failed=0 + while IFS= read -r file; do + rel=${file#"$extract_dir"/} + scan_file "$file" "$artifact:$rel" || failed=1 + done <<EOF +$(find "$extract_dir" -type f | sort) +EOF + rm -rf "$extract_dir" + return "$failed" +} + +checked=0 +failed=0 + +for artifact in "$@"; do + [ -f "$artifact" ] || { + printf 'leak_status=fail artifact=%s reason=missing\n' "$artifact" + failed=1 + continue + } + + checked=$((checked + 1)) + case "$artifact" in + *.tar.gz|*.tgz) scan_tar_gz "$artifact" || failed=1 ;; + *) scan_file "$artifact" "$artifact" || failed=1 ;; + esac +done + +printf 'checked_count=%s\n' "$checked" +if [ "$failed" -eq 0 ]; then + printf 'path_leak_status=pass\n' +else + printf 'path_leak_status=fail\n' + exit 1 +fi --- a/support/package-jerboa-release.sh +++ b/support/package-jerboa-release.sh @@ -100,6 +100,8 @@ else (cd "$tmp" && COPYFILE_DISABLE=1 tar -czf "$cwd/$versioned" "$name") fi +support/check-release-path-leaks.sh "$versioned" + if command -v sha256sum >/dev/null 2>&1; then sum=$(sha256sum "$versioned" | awk '{print $1}') elif command -v shasum >/dev/null 2>&1; then new file mode 100644 --- /dev/null +++ b/support/redact-build-paths.ss @@ -0,0 +1,97 @@ +#!chezscheme +;;; Replace absolute local build roots in binary artifacts with same-length bytes. + +(import (scheme)) + +(define (ascii-string->bytevector s) + (let* ([n (string-length s)] + [bv (make-bytevector n 0)]) + (let loop ([i 0]) + (when (< i n) + (bytevector-u8-set! bv i (char->integer (string-ref s i))) + (loop (+ i 1)))) + bv)) + +(define (read-file-bytevector path) + (call-with-port + (open-file-input-port path) + (lambda (p) (get-bytevector-all p)))) + +(define (write-file-bytevector path data) + (call-with-port + (open-file-output-port path (file-options replace) (buffer-mode block)) + (lambda (p) (put-bytevector p data)))) + +(define (bytevector-match-at? bv start needle) + (let ([n (bytevector-length needle)] + [limit (bytevector-length bv)]) + (and (<= (+ start n) limit) + (let loop ([i 0]) + (cond + [(= i n) #t] + [(= (bytevector-u8-ref bv (+ start i)) + (bytevector-u8-ref needle i)) + (loop (+ i 1))] + [else #f]))))) + +(define (redaction-bytevector len) + (make-bytevector len (char->integer #\x))) + +(define (replace-bytevector! data needle replacement) + (let ([needle-len (bytevector-length needle)] + [data-len (bytevector-length data)]) + (unless (= needle-len (bytevector-length replacement)) + (error 'replace-bytevector! "replacement length mismatch")) + (let loop ([i 0] [count 0]) + (cond + [(or (= needle-len 0) (> (+ i needle-len) data-len)) count] + [(bytevector-match-at? data i needle) + (bytevector-copy! replacement 0 data i needle-len) + (loop (+ i needle-len) (+ count 1))] + [else (loop (+ i 1) count)])))) + +(define (unique-strings xs) + (let loop ([rest xs] [seen '()] [out '()]) + (cond + [(null? rest) (reverse out)] + [(or (not (car rest)) + (= (string-length (car rest)) 0) + (string=? (car rest) "/") + (member (car rest) seen)) + (loop (cdr rest) seen out)] + [else + (loop (cdr rest) + (cons (car rest) seen) + (cons (car rest) out))]))) + +(define (redact-file! path patterns) + (let ([data (read-file-bytevector path)]) + (let loop ([rest patterns] [count 0]) + (if (null? rest) + (begin + (when (> count 0) + (write-file-bytevector path data) + (printf "redacted_build_path_count=~a artifact=~a~n" count path)) + count) + (let* ([needle (ascii-string->bytevector (car rest))] + [replacement (redaction-bytevector (bytevector-length needle))]) + (loop (cdr rest) + (+ count (replace-bytevector! data needle replacement)))))))) + +(define args + (let ([script-args (command-line-arguments)]) + (if (null? script-args) + (cdr (command-line)) + script-args))) + +(define patterns + (unique-strings + (list (getenv "JERBOA_HOME") + (getenv "HOME")))) + +(for-each + (lambda (path) + (unless (file-exists? path) + (error 'redact-build-paths "missing artifact" path)) + (redact-file! path patterns)) + args) new file mode 100755 --- /dev/null +++ b/tests/test-release-path-leaks.sh @@ -0,0 +1,36 @@ +#!/bin/sh + +set -eu + +repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-path-leaks-test.XXXXXX") +trap 'rm -rf "$tmp"' EXIT HUP INT TERM + +clean="$tmp/clean.bin" +leaky="$tmp/leaky.bin" +tar_root="$tmp/tar-root" +tarball="$tmp/leaky.tar.gz" +redacted="$tmp/redacted.bin" + +printf 'ordinary release payload\n' > "$clean" +"$repo/support/check-release-path-leaks.sh" "$clean" >/dev/null + +printf 'compiled from %s/lib/std/example.ss\n' "$HOME" > "$leaky" +if "$repo/support/check-release-path-leaks.sh" "$leaky" >/dev/null 2>&1; then + echo "expected HOME leak fixture to fail" >&2 + exit 1 +fi + +mkdir -p "$tar_root/bin" +printf 'compiled from %s/mcp/server.ss\n' "$repo" > "$tar_root/bin/jerboa" +(cd "$tar_root" && tar -czf "$tarball" .) +if "$repo/support/check-release-path-leaks.sh" "$tarball" >/dev/null 2>&1; then + echo "expected tarball repo-root leak fixture to fail" >&2 + exit 1 +fi + +printf 'compiled from %s/lib/std/example.ss\n' "$repo" > "$redacted" +JERBOA_HOME="$repo" "$repo/.chez/bin/scheme" --script "$repo/support/redact-build-paths.ss" "$redacted" >/dev/null +"$repo/support/check-release-path-leaks.sh" "$redacted" >/dev/null + +echo "release-path-leaks: PASS"