Harden in-place edits and native loading
ober
070e094bbb1a08a92e73aae072d9df954f58489f
--- a/.build.yml +++ b/.build.yml @@ -3,14 +3,13 @@ packages: - build-essential - ca-certificates - curl + - openssh-client - ripgrep - libpcre2-dev -environment: - JERBOA_VERSION: v0.2.3 tasks: - install-jerbuild: | - curl -fsSL "https://github.com/jerboa-lang/jerboa/releases/download/${JERBOA_VERSION}/jerbuild-linux-x86_64" -o "$HOME/jerbuild" - chmod +x "$HOME/jerbuild" + cd jerboa-sed + support/install-verified-jerbuild.sh "$HOME/jerbuild" - verify: | cd jerboa-sed JERBUILD="$HOME/jerbuild" make verify --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,18 +13,13 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install build tools - run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates ripgrep libpcre2-dev + run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates openssh-client ripgrep libpcre2-dev - name: Install jerbuild - run: | - set -eux - curl -fsSL "https://github.com/jerboa-lang/jerboa/releases/download/${JERBOA_VERSION}/jerbuild-linux-x86_64" -o /usr/local/bin/jerbuild - chmod +x /usr/local/bin/jerbuild - env: - JERBOA_VERSION: v0.2.3 + run: support/install-verified-jerbuild.sh /usr/local/bin/jerbuild - name: Verify run: make verify --- a/.github/workflows/security-baseline.yml +++ b/.github/workflows/security-baseline.yml @@ -13,7 +13,7 @@ jobs: baseline: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Required release files run: | --- a/.jerbuild +++ b/.jerbuild @@ -8,3 +8,5 @@ (entry "main.ss") (output "jsed") (libdirs "lib") +(extra-sources "support/secure-inplace.c") +(ffi-symbols "support/ffi-symbols.list") --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ SBOM_DIR ?= dist/sbom REPRO_DIR ?= dist/reproducibility TARGET_EVIDENCE_DIR ?= dist/target-evidence -.PHONY: all build binary run test test-parser parser-corpus test-cli-policy import-check clean-generated security audit verify sbom reproducibility-report target-evidence release-evidence clean install +.PHONY: all build binary run test test-parser test-security parser-corpus test-cli-policy import-check clean-generated security audit verify sbom reproducibility-report target-evidence release-evidence clean install all: binary @@ -57,7 +57,11 @@ test-cli-policy: binary @echo "--- script file I/O opt-in ---"; rm -f .jsed-test-out; echo ok | JSED_ALLOW_SCRIPT_FILE_IO=1 ./$(JSED_BIN) 'w .jsed-test-out' >/dev/null; grep -qx ok .jsed-test-out; rm -f .jsed-test-out @echo "--- script size cap ---"; printf 'pp' > .jsed-test-script.sed; if JSED_MAX_SCRIPT_CHARS=1 ./$(JSED_BIN) -f .jsed-test-script.sed >/tmp/jsed-size.out 2>/tmp/jsed-size.err; then cat /tmp/jsed-size.out /tmp/jsed-size.err; rm -f .jsed-test-script.sed; exit 1; fi; rm -f .jsed-test-script.sed -test: test-parser binary +test-security: binary tests/test-security.sh + @sh tests/native-loader-security-test.sh + @sh tests/test-security.sh + +test: test-parser binary test-security @echo "--- s/// ---"; echo "hello world" | ./$(JSED_BIN) 's/hello/goodbye/' @echo "--- = (line #) ---"; printf "a\nb\nc\n" | ./$(JSED_BIN) '=' @echo "--- 2d ---"; printf "a\nb\nc\n" | ./$(JSED_BIN) '2d' --- a/README.md +++ b/README.md @@ -27,7 +27,14 @@ Secure defaults: - `--sandbox` disables both script shell and script file effects even when the opt-in environment variables are set; - script text and input records are capped by `JSED_MAX_SCRIPT_CHARS` and - `JSED_MAX_INPUT_LINE_CHARS`, both defaulting to 1 MiB. + `JSED_MAX_INPUT_LINE_CHARS`, both defaulting to 1 MiB; +- compiled syntax trees are capped by `JSED_MAX_COMMANDS` (65,536 by default, + with a hard configuration ceiling of 1,000,000 nodes), including labels and + block containers; +- `-i` creates an unpredictable same-directory file with exclusive, + no-symlink-following descriptor operations, preserves owner/mode, syncs file + and directory data, verifies source/temp identities, and atomically renames. + A source symlink or unavailable native helper fails closed. CLI input files, `-f` script files, and explicit `-i` in-place edits are treated as caller intent and are still supported with path validation and taint checks. @@ -41,3 +48,7 @@ make verify ``` `jsed` depends on the platform `libpcre2-8` runtime library. +Standalone builds also link `support/secure-inplace.c`. Programs embedding the +`(sed main)` library must link and register the `jsed_secure_inplace_*` +symbols listed in `support/ffi-symbols.list` to enable `-i`; without them, +ordinary stream editing works but in-place editing is rejected. --- a/SECURITY.md +++ b/SECURITY.md @@ -31,6 +31,12 @@ Security-sensitive releases must be cut from a clean checkout after: environment opt-ins. - `JSED_MAX_SCRIPT_CHARS` and `JSED_MAX_INPUT_LINE_CHARS` cap parser and input memory use. Both default to 1 MiB. +- `JSED_MAX_COMMANDS` caps the complete parsed command tree, including labels + and block containers, at 65,536 nodes by default. +- In-place editing uses descriptor-owned random temporary files created with + exclusive/no-follow flags in the source directory, verifies file identities, + preserves owner/mode, and syncs before atomic publication. Final-component + source symlinks and missing secure-native support are rejected. CLI input files, `-f` script files, and explicit `-i` in-place edits are caller intent. They are not sandboxed, but paths are validated and taint-checked. --- a/lib/sed/engine.sls +++ b/lib/sed/engine.sls @@ -91,9 +91,44 @@ ;;; Compiled program (define-record-type sed-program (fields cmds labels)) + (define default-max-compiled-commands 65536) + + (define max-compiled-commands + (let ([value (getenv "JSED_MAX_COMMANDS")]) + (if value + (let ([n (string->number value)]) + (if (and n (integer? n) (> n 0) (<= n 1000000)) + n + default-max-compiled-commands)) + default-max-compiled-commands))) + + ;; Count every parsed node, including labels and block containers, before + ;; allocating the instruction buffer. Labels do not emit an instruction but + ;; must still consume the parser/compile resource budget. + (define (checked-command-count cmds) + (letrec ([count-list + (lambda (rest total) + (if (null? rest) + total + (let* ([cmd (car rest)] + [with-node (+ total 1)]) + (when (> with-node max-compiled-commands) + (error 'compile-program + "compiled command count exceeds configured limit" + with-node max-compiled-commands)) + (let ([with-children + (if (sed-cmd-block? cmd) + (count-list (sed-cmd-block-cmds cmd) with-node) + with-node)]) + (count-list (cdr rest) with-children)))))]) + (count-list cmds 0))) + ;;; Compile tree -> flat vector (define (compile-program cmds) - (let ([buf (make-vector 2048 #f)] + (let* ([command-count (checked-command-count cmds)] + ;; One slot is convenient for an empty program; only the first PC + ;; entries are returned below. + [buf (make-vector (if (= command-count 0) 1 command-count) #f)] [pc (box 0)] [labels (make-hashtable string-hash string=?)]) (define (emit! instr) --- a/lib/sed/main.sls +++ b/lib/sed/main.sls @@ -8,8 +8,8 @@ (export main run-sed) (import (except (scheme) compile-program) (only (std os env) getenv) - (only (std security taint) check-untainted! safe-delete-file safe-open-input-file) - (sed ast) (sed parser) (sed engine)) + (only (std security taint) check-untainted! safe-open-input-file) + (sed ast) (sed parser) (sed engine) (sed secure-file)) (define default-max-script-chars (* 1024 1024)) (define default-max-input-line-chars (* 1024 1024)) @@ -405,24 +405,10 @@ (for-each (lambda (filename) (unless (string=? filename "-") - (let* ([filename (check-sed-path! 'process-in-place filename)] - [tmp (check-sed-path! 'process-in-place - (string-append filename ".jsed-tmp"))]) - (guard (e [else - (display (string-append "sed: " filename ": ") - (current-error-port)) - (display-condition e (current-error-port)) - (newline (current-error-port)) - (when (file-exists? tmp) (safe-delete-file tmp))]) - (let ([out-port (open-file-output-port tmp - (file-options no-fail) - (buffer-mode block) - (make-transcoder (utf-8-codec)))]) - (process-one-file! state prog filename #t out-port) - (close-port out-port)) - (when (and suffix (not (string=? suffix ""))) - (rename-file filename (string-append filename suffix))) - (rename-file tmp filename))))) + (let ([filename (check-sed-path! 'process-in-place filename)]) + (call-with-secure-inplace-output filename suffix + (lambda (out-port) + (process-one-file! state prog filename #t out-port)))))) files)) ;;; Utilities --- a/lib/sed/pcre2.sls +++ b/lib/sed/pcre2.sls @@ -11,24 +11,36 @@ (import (scheme) (only (chezscheme) foreign-alloc foreign-free foreign-procedure foreign-ref - load-shared-object)) + foreign-entry?) + (only (std native-loader) native-loader-ensure-system-symbol!)) ;;; Constants (define PCRE2_CASELESS #x00000008) (define PCRE2_MULTILINE #x00000400) (define PCRE2_ZERO_TERMINATED (- (expt 2 64) 1)) - ;; Load shared library — guarded so static binaries (JERBOA_STATIC) don't - ;; abort at library init; pcre2 calls will fail at use time instead. - ;; Try the platform-appropriate names so the same binary works on - ;; Linux (.so), macOS (.dylib, incl. Homebrew), and FreeBSD. + ;; Prefer a symbol already registered in a static image. Dynamic builds may + ;; load PCRE2 only from reviewed absolute system/package-manager locations; + ;; bare names and current-directory loader searches are never candidates. (define init - (or (guard (e [#t #f]) (load-shared-object "libpcre2-8.so") #t) - (guard (e [#t #f]) (load-shared-object "libpcre2-8.dylib") #t) - (guard (e [#t #f]) (load-shared-object "libpcre2-8.so.0") #t) - (guard (e [#t #f]) (load-shared-object "/opt/homebrew/lib/libpcre2-8.dylib") #t) - (guard (e [#t #f]) (load-shared-object "/usr/local/lib/libpcre2-8.dylib") #t) - #f)) + (native-loader-ensure-system-symbol! + 'sed/pcre2 "pcre2_compile_8" + #f + "/opt/homebrew/lib/libpcre2-8.dylib" + "/opt/homebrew/opt/pcre2/lib/libpcre2-8.dylib" + "/usr/local/lib/libpcre2-8.dylib" + "/lib/x86_64-linux-gnu/libpcre2-8.so.0" + "/usr/lib/x86_64-linux-gnu/libpcre2-8.so.0" + "/lib/aarch64-linux-gnu/libpcre2-8.so.0" + "/usr/lib/aarch64-linux-gnu/libpcre2-8.so.0" + "/lib64/libpcre2-8.so.0" + "/usr/lib64/libpcre2-8.so.0" + "/usr/local/lib/libpcre2-8.so.0" + "/usr/local/lib/libpcre2-8.so" + "/usr/lib/libpcre2-8.so.0" + "/usr/lib/libpcre2-8.so" + "/lib/libpcre2-8.so.0" + "/lib/libpcre2-8.so")) ;;; FFI declarations — use string for PCRE2_SPTR (auto null-terminated), ;;; uptr for C pointers new file mode 100644 --- /dev/null +++ b/lib/sed/secure-file.sls @@ -0,0 +1,72 @@ +#!chezscheme +;;; Descriptor-owned, same-directory in-place output. + +(library (sed secure-file) + (export call-with-secure-inplace-output) + (import (scheme) + (only (chezscheme) foreign-entry? foreign-procedure)) + + (define native-ready? + (and (foreign-entry? "jsed_secure_inplace_begin") + (foreign-entry? "jsed_secure_inplace_dup_fd") + (foreign-entry? "jsed_secure_inplace_finish") + (foreign-entry? "jsed_secure_inplace_abort") + (foreign-entry? "jsed_secure_inplace_close_fd"))) + + (define c-begin + (and native-ready? + (foreign-procedure "jsed_secure_inplace_begin" (string) uptr))) + (define c-dup-fd + (and native-ready? + (foreign-procedure "jsed_secure_inplace_dup_fd" (uptr) int))) + (define c-finish + (and native-ready? + (foreign-procedure "jsed_secure_inplace_finish" (uptr string) int))) + (define c-abort + (and native-ready? + (foreign-procedure "jsed_secure_inplace_abort" (uptr) void))) + (define c-close + (and native-ready? + (foreign-procedure "jsed_secure_inplace_close_fd" (int) int))) + + (define (call-with-secure-inplace-output path suffix proc) + (unless native-ready? + (error 'jsed + "secure in-place helper is unavailable; rebuild with support/secure-inplace.c")) + (let ([ctx (c-begin path)] [finished? #f]) + (when (= ctx 0) + (error 'jsed "cannot securely open in-place input" path)) + (dynamic-wind + (lambda () #t) + (lambda () + (let ([fd (c-dup-fd ctx)]) + (when (< fd 0) + (error 'jsed "cannot duplicate secure temporary descriptor" path)) + (let ([port + (guard (e [else + (c-close fd) + (raise e)]) + (open-fd-output-port fd + (buffer-mode block) + (make-transcoder (utf-8-codec))))] + [port-open? #t] + [result #f]) + (dynamic-wind + (lambda () #t) + (lambda () + (set! result (proc port)) + (flush-output-port port)) + (lambda () + (when port-open? + ;; close(2) ownership transfers to the port exactly once. + (set! port-open? #f) + (close-port port)))) + (let ([rc (c-finish ctx (or suffix ""))]) + ;; c-finish consumes CTX on success and failure. + (set! finished? #t) + (when (< rc 0) + (error 'jsed "secure in-place commit failed" path rc))) + result))) + (lambda () + (unless finished? (c-abort ctx)))))) +) --- a/main.ss +++ b/main.ss @@ -3,8 +3,8 @@ (import (except (scheme) compile-program) (only (std os env) getenv) - (only (std security taint) check-untainted! safe-delete-file safe-open-input-file) - (sed ast) (sed parser) (sed engine)) + (only (std security taint) check-untainted! safe-open-input-file) + (sed ast) (sed parser) (sed engine) (sed secure-file)) (define default-max-script-chars (* 1024 1024)) (define default-max-input-line-chars (* 1024 1024)) @@ -400,24 +400,10 @@ (for-each (lambda (filename) (unless (string=? filename "-") - (let* ([filename (check-sed-path! 'process-in-place filename)] - [tmp (check-sed-path! 'process-in-place - (string-append filename ".jsed-tmp"))]) - (guard (e [else - (display (string-append "sed: " filename ": ") - (current-error-port)) - (display-condition e (current-error-port)) - (newline (current-error-port)) - (when (file-exists? tmp) (safe-delete-file tmp))]) - (let ([out-port (open-file-output-port tmp - (file-options no-fail) - (buffer-mode block) - (make-transcoder (utf-8-codec)))]) - (process-one-file! state prog filename #t out-port) - (close-port out-port)) - (when (and suffix (not (string=? suffix ""))) - (rename-file filename (string-append filename suffix))) - (rename-file tmp filename))))) + (let ([filename (check-sed-path! 'process-in-place filename)]) + (call-with-secure-inplace-output filename suffix + (lambda (out-port) + (process-one-file! state prog filename #t out-port)))))) files)) ;;; Utilities --- a/scripts/security-check.sh +++ b/scripts/security-check.sh @@ -18,8 +18,12 @@ required=( scripts/target-evidence.sh scripts/sanitize-evidence.sh support/import-check.ss + support/secure-inplace.c + support/ffi-symbols.list support/parser-corpus-evidence.ss tests/test-parser.ss + tests/native-loader-security-test.sh + tests/test-security.sh ) for path in "${required[@]}"; do @@ -72,9 +76,9 @@ chez_matches="$( grep -R -n -E '\(chezscheme\)' --include='*.ss' --include='*.sls' . \ --exclude-dir=.git --exclude-dir=dist --exclude-dir=.build || true )" -if [[ -n "$chez_matches" ]] && grep -v 'lib/sed/pcre2.sls' <<<"$chez_matches" >/dev/null; then +if [[ -n "$chez_matches" ]] && grep -v -E 'lib/sed/(pcre2|secure-file)[.]sls' <<<"$chez_matches" >/dev/null; then echo "$chez_matches" >&2 - echo "targeted Chez FFI imports are only allowed in lib/sed/pcre2.sls." >&2 + echo "targeted Chez FFI imports are only allowed in reviewed sed FFI modules." >&2 exit 1 fi @@ -83,12 +87,22 @@ ffi_matches="$( --include='*.ss' --include='*.sls' . \ --exclude-dir=.git --exclude-dir=dist --exclude-dir=.build || true )" -if [[ -n "$ffi_matches" ]] && grep -v 'lib/sed/pcre2.sls' <<<"$ffi_matches" >/dev/null; then +if [[ -n "$ffi_matches" ]] && grep -v -E 'lib/sed/(pcre2|secure-file)[.]sls' <<<"$ffi_matches" >/dev/null; then echo "$ffi_matches" >&2 - echo "FFI is only allowed in lib/sed/pcre2.sls." >&2 + echo "FFI is only allowed in reviewed sed FFI modules." >&2 exit 1 fi +grep -q 'native-loader-ensure-system-symbol!' lib/sed/pcre2.sls || { + echo "PCRE2 resolution bypasses the shared native-loader policy." >&2 + exit 1 +} +if grep -Eq 'load-shared-object[[:space:]]+"libpcre2' lib/sed/pcre2.sls; then + echo "bare/CWD PCRE2 loader remains." >&2 + exit 1 +fi +sh tests/native-loader-security-test.sh + process_matches="$( grep -R -n -E 'open-process-ports|\(system[[:space:]]' \ --include='*.ss' --include='*.sls' . \ @@ -112,6 +126,15 @@ fi grep -q 'JSED_ALLOW_SCRIPT_FILE_IO' lib/sed/engine.sls grep -q 'JSED_MAX_SCRIPT_CHARS' lib/sed/parser.sls grep -q 'JSED_MAX_INPUT_LINE_CHARS' main.ss +grep -q 'JSED_MAX_COMMANDS' lib/sed/engine.sls +grep -q 'O_EXCL.*O_NOFOLLOW' support/secure-inplace.c +grep -q 'fchown' support/secure-inplace.c +grep -q 'fchmod' support/secure-inplace.c +grep -q 'fsync(ctx->temp_fd)' support/secure-inplace.c +if grep -q '[.]jsed-tmp"' main.ss lib/sed/main.sls; then + echo "predictable in-place temporary path found" >&2 + exit 1 +fi grep -q 'parser_corpus_status: documented' docs/parser-hardening.md grep -q 'parser_corpus_cases_minimum: 512' docs/parser-hardening.md grep -q 'parser_corpus_scope_status: substitution-address-block-file-shell-malformed' docs/parser-hardening.md @@ -166,4 +189,21 @@ if [[ -n "$secret_matches" ]]; then exit 1 fi +for bootstrap_file in \ + support/install-verified-jerbuild.sh \ + support/jerbuild-bootstrap.lock \ + support/jerboa-release-signers; do + test -f "$bootstrap_file" || { echo "missing authenticated bootstrap input: $bootstrap_file" >&2; exit 1; } +done +grep -q 'ssh-keygen -Y verify' support/install-verified-jerbuild.sh +grep -q '^status=blocked-awaiting-authenticated-upstream-release$' support/jerbuild-bootstrap.lock +if grep -R -n -E 'jerbuild-linux|releases/download/.*/jerbuild' .github/workflows .build.yml 2>/dev/null; then + echo "unsigned Jerbuild bootstrap remains" >&2 + exit 1 +fi +if grep -R -n -E 'uses:[[:space:]]+[^[:space:]#]+@(v[0-9]+|main|master|stable|latest)([[:space:]#]|$)' .github/workflows 2>/dev/null; then + echo "mutable GitHub Action reference remains" >&2 + exit 1 +fi + echo "security-check: ok" new file mode 100644 --- /dev/null +++ b/support/ffi-symbols.list @@ -0,0 +1,6 @@ +# Native in-place-edit helper linked by .jerbuild. +jsed_secure_inplace_begin +jsed_secure_inplace_dup_fd +jsed_secure_inplace_finish +jsed_secure_inplace_abort +jsed_secure_inplace_close_fd new file mode 100755 --- /dev/null +++ b/support/install-verified-jerbuild.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +lock=${JERBOA_JERBUILD_LOCK:-"$root/support/jerbuild-bootstrap.lock"} +signers="$root/support/jerboa-release-signers" +dest=${1:-"$root/.deps/bin/jerbuild"} + +read_value() { + local key=$1 + sed -n "s/^${key}=//p" "$lock" | tail -n 1 +} + +[[ -f "$lock" ]] || { echo "verified bootstrap blocked: missing lock" >&2; exit 1; } +status=$(read_value status) +if [[ "$status" != ready ]]; then + echo "verified bootstrap blocked: ${status:-lock-status-missing}" >&2 + exit 1 +fi + +base_url=$(read_value base_url) +asset=$(read_value asset) +expected=$(read_value sha256) +manifest=$(read_value manifest) +signature=$(read_value signature) +identity=$(read_value signer_identity) +namespace=$(read_value namespace) + +[[ "$base_url" == https://* ]] || { echo "bootstrap base URL must use HTTPS" >&2; exit 1; } +[[ "$asset" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe bootstrap asset name" >&2; exit 1; } +[[ "$manifest" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe manifest name" >&2; exit 1; } +[[ "$signature" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe signature name" >&2; exit 1; } +[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { echo "invalid pinned SHA-256" >&2; exit 1; } +grep -Eq '^[[:space:]]*[^#[:space:]][^[:space:]]*[[:space:]]+ssh-(ed25519|rsa)[[:space:]]+' "$signers" || { + echo "verified bootstrap blocked: pinned signer entry missing" >&2 + exit 1 +} +command -v ssh-keygen >/dev/null || { echo "ssh-keygen is required" >&2; exit 1; } + +umask 077 +tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-bootstrap.XXXXXXXX") +trap 'rm -rf "$tmp"' EXIT +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "$base_url/$manifest" --output "$tmp/$manifest" +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "$base_url/$signature" --output "$tmp/$signature" +ssh-keygen -Y verify -f "$signers" -I "$identity" -n "${namespace:-file}" \ + -s "$tmp/$signature" < "$tmp/$manifest" + +manifest_digest=$(awk -v name="$asset" '$2 == name {print $1}' "$tmp/$manifest") +[[ "$manifest_digest" == "$expected" ]] || { echo "signed manifest digest mismatch" >&2; exit 1; } +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "$base_url/$asset" --output "$tmp/$asset" +actual=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}') +[[ "$actual" == "$expected" ]] || { echo "jerbuild digest mismatch" >&2; exit 1; } +mkdir -p "$(dirname "$dest")" +install -m 0755 "$tmp/$asset" "$dest" +printf 'verified jerbuild sha256=%s\n' "$actual" new file mode 100644 --- /dev/null +++ b/support/jerboa-release-signers @@ -0,0 +1,2 @@ +# OpenSSH allowed-signers entries belong here. No authenticated Jerboa release +# identity has been published, so CI bootstrap remains deliberately blocked. new file mode 100644 --- /dev/null +++ b/support/jerbuild-bootstrap.lock @@ -0,0 +1,11 @@ +# Fail closed until the Jerboa producer publishes a signed manifest and a +# release identity is provisioned below. A versioned same-origin asset alone +# is not an authenticated bootstrap. +status=blocked-awaiting-authenticated-upstream-release +base_url= +asset= +sha256= +manifest= +signature= +signer_identity=jerboa-release +namespace=file new file mode 100644 --- /dev/null +++ b/support/secure-inplace.c @@ -0,0 +1,341 @@ +#define _GNU_SOURCE 1 +#define _DARWIN_C_SOURCE 1 +#define _POSIX_C_SOURCE 200809L + +#include <errno.h> +#include <fcntl.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <unistd.h> + +#if defined(__linux__) +#include <sys/random.h> +#endif + +#ifndef O_DIRECTORY +#error "secure in-place editing requires O_DIRECTORY" +#endif +#ifndef O_NOFOLLOW +#error "secure in-place editing requires O_NOFOLLOW" +#endif +#ifndef AT_SYMLINK_NOFOLLOW +#error "secure in-place editing requires AT_SYMLINK_NOFOLLOW" +#endif + +#ifndef O_CLOEXEC +#define O_CLOEXEC 0 +#endif + +typedef struct jsed_inplace_ctx { + int dir_fd; + int source_fd; + int temp_fd; + dev_t source_dev; + ino_t source_ino; + dev_t temp_dev; + ino_t temp_ino; + char *base_name; + char temp_name[64]; + int temp_linked; +} jsed_inplace_ctx; + +static int close_once(int fd) { + if (fd < 0) return 0; + if (close(fd) == 0) return 0; +#if defined(__linux__) + /* Linux consumes the descriptor even when close reports EINTR. */ + if (errno == EINTR) return 0; +#endif + return -1; +} + +static int random_bytes(unsigned char *out, size_t len) { +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) + arc4random_buf(out, len); + return 0; +#elif defined(__linux__) + size_t off = 0; + while (off < len) { + ssize_t n = getrandom(out + off, len - off, 0); + if (n > 0) { + off += (size_t)n; + } else if (n < 0 && errno == EINTR) { + continue; + } else if (n < 0 && errno == ENOSYS) { + break; + } else { + return -1; + } + } + if (off == len) return 0; +#endif + + int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) return -1; + size_t read_off = 0; + while (read_off < len) { + ssize_t n = read(fd, out + read_off, len - read_off); + if (n > 0) { + read_off += (size_t)n; + } else if (n < 0 && errno == EINTR) { + continue; + } else { + int saved = (n < 0) ? errno : EIO; + (void)close_once(fd); + errno = saved; + return -1; + } + } + if (close_once(fd) < 0) return -1; + return 0; +} + +static int split_path(const char *path, char **dir_out, char **base_out) { + const char *slash; + char *dir; + char *base; + + if (!path || !path[0]) { + errno = EINVAL; + return -1; + } + slash = strrchr(path, '/'); + if (!slash) { + dir = strdup("."); + base = strdup(path); + } else if (slash == path) { + dir = strdup("/"); + base = strdup(slash + 1); + } else { + size_t dir_len = (size_t)(slash - path); + dir = (char *)malloc(dir_len + 1); + if (dir) { + memcpy(dir, path, dir_len); + dir[dir_len] = '\0'; + } + base = strdup(slash + 1); + } + if (!dir || !base) { + free(dir); + free(base); + errno = ENOMEM; + return -1; + } + if (!base[0] || strcmp(base, ".") == 0 || strcmp(base, "..") == 0 || + strchr(base, '/')) { + free(dir); + free(base); + errno = EINVAL; + return -1; + } + *dir_out = dir; + *base_out = base; + return 0; +} + +static int same_identity(const struct stat *st, dev_t dev, ino_t ino) { + return st->st_dev == dev && st->st_ino == ino; +} + +static void unlink_owned_temp(jsed_inplace_ctx *ctx) { + struct stat st; + if (!ctx || !ctx->temp_linked || ctx->dir_fd < 0) return; + if (fstatat(ctx->dir_fd, ctx->temp_name, &st, AT_SYMLINK_NOFOLLOW) == 0 && + same_identity(&st, ctx->temp_dev, ctx->temp_ino)) { + (void)unlinkat(ctx->dir_fd, ctx->temp_name, 0); + } + ctx->temp_linked = 0; +} + +static void destroy_ctx(jsed_inplace_ctx *ctx, int remove_temp) { + if (!ctx) return; + if (remove_temp) unlink_owned_temp(ctx); + (void)close_once(ctx->temp_fd); + (void)close_once(ctx->source_fd); + (void)close_once(ctx->dir_fd); + free(ctx->base_name); + free(ctx); +} + +uintptr_t jsed_secure_inplace_begin(const char *path) { + jsed_inplace_ctx *ctx = NULL; + char *dir_name = NULL; + char *base_name = NULL; + struct stat source_st; + struct stat temp_st; + unsigned char random[16]; + static const char hex[] = "0123456789abcdef"; + static const char prefix[] = ".jsed-tmp-"; + const size_t prefix_len = sizeof(prefix) - 1; + + if (split_path(path, &dir_name, &base_name) < 0) return (uintptr_t)0; + ctx = (jsed_inplace_ctx *)calloc(1, sizeof(*ctx)); + if (!ctx) { + free(dir_name); + free(base_name); + return (uintptr_t)0; + } + ctx->dir_fd = -1; + ctx->source_fd = -1; + ctx->temp_fd = -1; + ctx->base_name = base_name; + + ctx->dir_fd = open(dir_name, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + free(dir_name); + if (ctx->dir_fd < 0) goto fail; + + ctx->source_fd = openat(ctx->dir_fd, ctx->base_name, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (ctx->source_fd < 0 || fstat(ctx->source_fd, &source_st) < 0) + goto fail; + if (!S_ISREG(source_st.st_mode)) { + errno = EINVAL; + goto fail; + } + ctx->source_dev = source_st.st_dev; + ctx->source_ino = source_st.st_ino; + + for (int attempt = 0; attempt < 128; ++attempt) { + if (random_bytes(random, sizeof(random)) < 0) goto fail; + memcpy(ctx->temp_name, prefix, prefix_len); + for (size_t i = 0; i < sizeof(random); ++i) { + ctx->temp_name[prefix_len + i * 2] = hex[random[i] >> 4]; + ctx->temp_name[prefix_len + i * 2 + 1] = hex[random[i] & 15]; + } + ctx->temp_name[prefix_len + sizeof(random) * 2] = '\0'; + ctx->temp_fd = openat(ctx->dir_fd, ctx->temp_name, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + 0600); + if (ctx->temp_fd >= 0) break; + if (errno != EEXIST) goto fail; + } + if (ctx->temp_fd < 0) { + errno = EEXIST; + goto fail; + } + ctx->temp_linked = 1; + + if (fstat(ctx->temp_fd, &temp_st) < 0) { + (void)unlinkat(ctx->dir_fd, ctx->temp_name, 0); + ctx->temp_linked = 0; + goto fail; + } + ctx->temp_dev = temp_st.st_dev; + ctx->temp_ino = temp_st.st_ino; + if (fchown(ctx->temp_fd, source_st.st_uid, source_st.st_gid) < 0 || + fchmod(ctx->temp_fd, source_st.st_mode & 07777) < 0) { + goto fail; + } + return (uintptr_t)ctx; + +fail: + { + int saved = errno ? errno : EIO; + destroy_ctx(ctx, 1); + errno = saved; + return (uintptr_t)0; + } +} + +int jsed_secure_inplace_dup_fd(uintptr_t handle) { + jsed_inplace_ctx *ctx = (jsed_inplace_ctx *)handle; + if (!ctx || ctx->temp_fd < 0) return -EINVAL; +#ifdef F_DUPFD_CLOEXEC + int fd = fcntl(ctx->temp_fd, F_DUPFD_CLOEXEC, 3); +#else + int fd = dup(ctx->temp_fd); + if (fd >= 0 && fcntl(fd, F_SETFD, FD_CLOEXEC) < 0) { + int saved = errno; + (void)close_once(fd); + errno = saved; + fd = -1; + } +#endif + return fd < 0 ? -errno : fd; +} + +static int suffix_valid(const char *suffix) { + return suffix && !strchr(suffix, '/'); +} + +int jsed_secure_inplace_finish(uintptr_t handle, const char *suffix) { + jsed_inplace_ctx *ctx = (jsed_inplace_ctx *)handle; + struct stat source_now; + struct stat temp_now; + char *backup = NULL; + int moved_source = 0; + int saved = 0; + + if (!ctx) return -EINVAL; + if (!suffix_valid(suffix)) { + saved = EINVAL; + goto fail; + } + if (fsync(ctx->temp_fd) < 0 || + fstat(ctx->temp_fd, &temp_now) < 0 || + !same_identity(&temp_now, ctx->temp_dev, ctx->temp_ino) || + fstatat(ctx->dir_fd, ctx->temp_name, &temp_now, AT_SYMLINK_NOFOLLOW) < 0 || + !same_identity(&temp_now, ctx->temp_dev, ctx->temp_ino) || + fstatat(ctx->dir_fd, ctx->base_name, &source_now, AT_SYMLINK_NOFOLLOW) < 0 || + !S_ISREG(source_now.st_mode) || + !same_identity(&source_now, ctx->source_dev, ctx->source_ino)) { + saved = errno ? errno : ESTALE; + goto fail; + } + + if (suffix[0]) { + size_t base_len = strlen(ctx->base_name); + size_t suffix_len = strlen(suffix); + if (base_len > SIZE_MAX - suffix_len - 1) { + saved = EOVERFLOW; + goto fail; + } + backup = (char *)malloc(base_len + suffix_len + 1); + if (!backup) { + saved = ENOMEM; + goto fail; + } + memcpy(backup, ctx->base_name, base_len); + memcpy(backup + base_len, suffix, suffix_len + 1); + if (renameat(ctx->dir_fd, ctx->base_name, ctx->dir_fd, backup) < 0) { + saved = errno; + goto fail; + } + moved_source = 1; + } + + if (renameat(ctx->dir_fd, ctx->temp_name, + ctx->dir_fd, ctx->base_name) < 0) { + saved = errno; + if (moved_source) + (void)renameat(ctx->dir_fd, backup, ctx->dir_fd, ctx->base_name); + goto fail; + } + ctx->temp_linked = 0; + if (fsync(ctx->dir_fd) < 0) { + saved = errno; + goto fail; + } + + free(backup); + destroy_ctx(ctx, 0); + return 0; + +fail: + free(backup); + destroy_ctx(ctx, 1); + return -(saved ? saved : EIO); +} + +void jsed_secure_inplace_abort(uintptr_t handle) { + destroy_ctx((jsed_inplace_ctx *)handle, 1); +} + +int jsed_secure_inplace_close_fd(int fd) { + if (fd < 0) return -EINVAL; + return close_once(fd) == 0 ? 0 : -errno; +} new file mode 100644 --- /dev/null +++ b/tests/native-loader-security-test.sh @@ -0,0 +1,125 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +if [ -n "${JERBUILD:-}" ]; then + jerbuild=$JERBUILD +elif [ -x "$root/.jerboa/bin/jerbuild" ]; then + jerbuild=$root/.jerboa/bin/jerbuild +elif [ -x "$root/../jerboa/dist/jerbuild" ]; then + jerbuild=$root/../jerboa/dist/jerbuild +else + jerbuild=$(command -v jerbuild || true) +fi +[ -n "$jerbuild" ] || { + echo "native-loader-security: jerbuild is required" >&2 + exit 1 +} +case $jerbuild in + */*) jerbuild=$(CDPATH= cd -- "$(dirname -- "$jerbuild")" && pwd -P)/$(basename -- "$jerbuild") ;; + *) jerbuild=$(command -v "$jerbuild" || true) ;; +esac +[ -n "$jerbuild" ] && [ -x "$jerbuild" ] || { + echo "native-loader-security: jerbuild is not executable" >&2 + exit 1 +} + +jh=$("$jerbuild" --jerboa-home) +core_lib= +for candidate in "$jh/lib" "$root/../jerboa/lib"; do + if [ -f "$candidate/std/native-loader.ss" ] && + grep -q 'native-loader-ensure-system-symbol!' "$candidate/std/native-loader.ss"; then + core_lib=$candidate + break + fi +done +[ -n "$core_lib" ] || { + echo "native-loader-security: updated (std native-loader) is unavailable" >&2 + exit 1 +} + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-sed-loader.XXXXXX") +cleanup() { rm -rf -- "$tmp"; } +trap cleanup EXIT HUP INT TERM +mkdir -p "$tmp/hostile" + +cat >"$tmp/sentinel.c" <<'EOF' +#include <stdio.h> +#include <stdlib.h> +__attribute__((constructor)) static void loaded(void) { + const char *marker = getenv("JERBOA_LOADER_SENTINEL_MARKER"); + if (marker) { + FILE *out = fopen(marker, "w"); + if (out) { fputs("loaded\n", out); fclose(out); } + } +} +EOF +case $(uname -s) in + Darwin) ${CC:-cc} -dynamiclib -fPIC -o "$tmp/sentinel" "$tmp/sentinel.c" ;; + *) ${CC:-cc} -shared -fPIC -o "$tmp/sentinel" "$tmp/sentinel.c" ;; +esac +for name in libpcre2-8.so libpcre2-8.dylib libpcre2-8.so.0; do + cp "$tmp/sentinel" "$tmp/hostile/$name" +done + +cat >"$tmp/bare-control.ss" <<'EOF' +#!chezscheme +(import (scheme)) +(load-shared-object "libpcre2-8.so") +EOF +control_marker=$tmp/bare-control-loaded +case $(uname -s) in + Darwin) + (cd "$tmp/hostile" && + DYLD_FALLBACK_LIBRARY_PATH="$tmp/hostile${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" \ + JERBOA_LOADER_SENTINEL_MARKER="$control_marker" \ + "$jerbuild" exec --libdirs "$jh/lib" "$tmp/bare-control.ss") + ;; + *) + (cd "$tmp/hostile" && + LD_LIBRARY_PATH="$tmp/hostile${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + JERBOA_LOADER_SENTINEL_MARKER="$control_marker" \ + "$jerbuild" exec --libdirs "$jh/lib" "$tmp/bare-control.ss") + ;; +esac +[ -e "$control_marker" ] || { + echo "native-loader-security: bare-name negative control did not load sentinel" >&2 + exit 1 +} +rm -f "$control_marker" + +cat >"$tmp/probe.ss" <<'EOF' +#!chezscheme +(import (scheme) (sed pcre2)) +(let ([rx (pcre2-compile "a+" 0)]) + (unless (pcre2-matches? rx "caaab") + (error 'sed-native-loader-probe "PCRE2 match failed")) + (pcre2-free rx)) +(display "sed-native-loader-import: pass\n") +EOF + +marker=$tmp/sentinel-loaded +rc=0 +case $(uname -s) in + Darwin) + (cd "$tmp/hostile" && + DYLD_FALLBACK_LIBRARY_PATH="$tmp/hostile${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" \ + JERBOA_LOADER_SENTINEL_MARKER="$marker" \