Bound assembler inputs and verify bootstrap
ober
ea2637422bf3de682bc08afd11eb55afdb58afc3
--- 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 + run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates openssh-client ripgrep - 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: Install scanner tools run: sudo apt-get update && sudo apt-get install -y ripgrep --- 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 check test-binary import-check object-fuzz-evidence clean-generated security audit verify sbom reproducibility-report target-evidence release-evidence clean +.PHONY: all build binary run test check test-binary test-input-limit import-check object-fuzz-evidence clean-generated security audit verify sbom reproducibility-report target-evidence release-evidence clean all: binary @@ -57,7 +57,7 @@ audit: security target-evidence: scripts/target-evidence.sh @REPO_ROOT=. TARGET_EVIDENCE_DIR="$(TARGET_EVIDENCE_DIR)" sh scripts/target-evidence.sh -verify: audit test object-fuzz-evidence test-binary target-evidence +verify: audit test object-fuzz-evidence test-binary test-input-limit target-evidence sbom: REPO_ROOT="$(CURDIR)" SBOM_DIR="$(SBOM_DIR)" JERBUILD="$(JERBUILD)" JERBOA_VERSION="$(JERBOA_VERSION)" sh scripts/sbom.sh @@ -98,6 +98,9 @@ test-binary: binary ./$(BINARY) raw --arch aarch64 --hex '20 00 80 d2 c0 03 5f d6' ./$(BINARY) raw --arch riscv64 --hex '13 01 01 ff 67 80 00 00' +test-input-limit: binary tests/test-input-limit.sh + @REPO_ROOT="$(CURDIR)" sh tests/test-input-limit.sh + clean: rm -f $(BINARY) $(BINARY).wp.so $(BINARY)-main.c petite_boot.h scheme_boot.h program_boot.h .jasm-sample.o .jasm-test-sample.o .jasm-raw.bin find . \( -name '*.so' -o -name '*.wpo' -o -name '*.o' \) -print0 | xargs -0 rm -f --- a/lib/jasm/object/archive.ss +++ b/lib/jasm/object/archive.ss @@ -55,17 +55,42 @@ (define max-object-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-object-bytes)) - (define (enforce-input-size who path bytes) - (when (> (bytevector-length bytes) max-object-bytes) - (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-object-bytes)) - bytes) + (define input-read-chunk-bytes 65536) + + (define (chunks->bytevector chunks total) + (let ([out (make-bytevector total 0)]) + (let loop ([chunks (reverse chunks)] [offset 0]) + (unless (null? chunks) + (let* ([chunk (car chunks)] [size (bytevector-length chunk)]) + (bytevector-copy! chunk 0 out offset size) + (loop (cdr chunks) (+ offset size))))) + out)) + + (define (read-bounded-bytevector who path port limit) + (let ([reported-size (and (port-has-port-position? port) + (file-length port))]) + (when (and reported-size (> reported-size limit)) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit))) + (let ([buffer (make-bytevector input-read-chunk-bytes 0)]) + (let loop ([chunks '()] [total 0]) + (let* ([remaining (+ (- limit total) 1)] + [want (min input-read-chunk-bytes remaining)] + [count (get-bytevector-n! port buffer 0 want)]) + (if (eof-object? count) + (chunks->bytevector chunks total) + (let ([next-total (+ total count)]) + (when (> next-total limit) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit)) + (let ([chunk (make-bytevector count 0)]) + (bytevector-copy! buffer 0 chunk 0 count) + (loop (cons chunk chunks) next-total)))))))) (define (read-file-bytevector path) (let ([port (open-file-input-port path)]) (dynamic-wind (lambda () (void)) (lambda () - (enforce-input-size 'read-archive-file path (get-bytevector-all port))) + (read-bounded-bytevector 'read-archive-file path port max-object-bytes)) (lambda () (close-port port))))) (define (read-fixed-string bv offset size) --- a/lib/jasm/object/coff.ss +++ b/lib/jasm/object/coff.ss @@ -72,17 +72,42 @@ (define max-object-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-object-bytes)) - (define (enforce-input-size who path bytes) - (when (> (bytevector-length bytes) max-object-bytes) - (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-object-bytes)) - bytes) + (define input-read-chunk-bytes 65536) + + (define (chunks->bytevector chunks total) + (let ([out (make-bytevector total 0)]) + (let loop ([chunks (reverse chunks)] [offset 0]) + (unless (null? chunks) + (let* ([chunk (car chunks)] [size (bytevector-length chunk)]) + (bytevector-copy! chunk 0 out offset size) + (loop (cdr chunks) (+ offset size))))) + out)) + + (define (read-bounded-bytevector who path port limit) + (let ([reported-size (and (port-has-port-position? port) + (file-length port))]) + (when (and reported-size (> reported-size limit)) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit))) + (let ([buffer (make-bytevector input-read-chunk-bytes 0)]) + (let loop ([chunks '()] [total 0]) + (let* ([remaining (+ (- limit total) 1)] + [want (min input-read-chunk-bytes remaining)] + [count (get-bytevector-n! port buffer 0 want)]) + (if (eof-object? count) + (chunks->bytevector chunks total) + (let ([next-total (+ total count)]) + (when (> next-total limit) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit)) + (let ([chunk (make-bytevector count 0)]) + (bytevector-copy! buffer 0 chunk 0 count) + (loop (cons chunk chunks) next-total)))))))) (define (read-file-bytevector path) (let ([port (open-file-input-port path)]) (dynamic-wind (lambda () (void)) (lambda () - (enforce-input-size 'read-coff-file path (get-bytevector-all port))) + (read-bounded-bytevector 'read-coff-file path port max-object-bytes)) (lambda () (close-port port))))) (define (read-fixed-string bv offset size) --- a/lib/jasm/object/elf.ss +++ b/lib/jasm/object/elf.ss @@ -141,17 +141,42 @@ (define max-object-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-object-bytes)) - (define (enforce-input-size who path bytes) - (when (> (bytevector-length bytes) max-object-bytes) - (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-object-bytes)) - bytes) + (define input-read-chunk-bytes 65536) + + (define (chunks->bytevector chunks total) + (let ([out (make-bytevector total 0)]) + (let loop ([chunks (reverse chunks)] [offset 0]) + (unless (null? chunks) + (let* ([chunk (car chunks)] [size (bytevector-length chunk)]) + (bytevector-copy! chunk 0 out offset size) + (loop (cdr chunks) (+ offset size))))) + out)) + + (define (read-bounded-bytevector who path port limit) + (let ([reported-size (and (port-has-port-position? port) + (file-length port))]) + (when (and reported-size (> reported-size limit)) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit))) + (let ([buffer (make-bytevector input-read-chunk-bytes 0)]) + (let loop ([chunks '()] [total 0]) + (let* ([remaining (+ (- limit total) 1)] + [want (min input-read-chunk-bytes remaining)] + [count (get-bytevector-n! port buffer 0 want)]) + (if (eof-object? count) + (chunks->bytevector chunks total) + (let ([next-total (+ total count)]) + (when (> next-total limit) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit)) + (let ([chunk (make-bytevector count 0)]) + (bytevector-copy! buffer 0 chunk 0 count) + (loop (cons chunk chunks) next-total)))))))) (define (read-file-bytevector path) (let ([port (open-file-input-port path)]) (dynamic-wind (lambda () (void)) (lambda () - (enforce-input-size 'read-elf-file path (get-bytevector-all port))) + (read-bounded-bytevector 'read-elf-file path port max-object-bytes)) (lambda () (close-port port))))) (define (elf-class-name n) --- a/lib/jasm/object/macho.ss +++ b/lib/jasm/object/macho.ss @@ -114,17 +114,42 @@ (define max-object-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-object-bytes)) - (define (enforce-input-size who path bytes) - (when (> (bytevector-length bytes) max-object-bytes) - (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-object-bytes)) - bytes) + (define input-read-chunk-bytes 65536) + + (define (chunks->bytevector chunks total) + (let ([out (make-bytevector total 0)]) + (let loop ([chunks (reverse chunks)] [offset 0]) + (unless (null? chunks) + (let* ([chunk (car chunks)] [size (bytevector-length chunk)]) + (bytevector-copy! chunk 0 out offset size) + (loop (cdr chunks) (+ offset size))))) + out)) + + (define (read-bounded-bytevector who path port limit) + (let ([reported-size (and (port-has-port-position? port) + (file-length port))]) + (when (and reported-size (> reported-size limit)) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit))) + (let ([buffer (make-bytevector input-read-chunk-bytes 0)]) + (let loop ([chunks '()] [total 0]) + (let* ([remaining (+ (- limit total) 1)] + [want (min input-read-chunk-bytes remaining)] + [count (get-bytevector-n! port buffer 0 want)]) + (if (eof-object? count) + (chunks->bytevector chunks total) + (let ([next-total (+ total count)]) + (when (> next-total limit) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit)) + (let ([chunk (make-bytevector count 0)]) + (bytevector-copy! buffer 0 chunk 0 count) + (loop (cons chunk chunks) next-total)))))))) (define (read-file-bytevector path) (let ([port (open-file-input-port path)]) (dynamic-wind (lambda () (void)) (lambda () - (enforce-input-size 'read-macho-file path (get-bytevector-all port))) + (read-bounded-bytevector 'read-macho-file path port max-object-bytes)) (lambda () (close-port port))))) (define (read-fixed-string bv offset size) --- a/main.ss +++ b/main.ss @@ -47,17 +47,54 @@ (def max-input-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-input-bytes)) -(def (enforce-input-size who path bytes) - (when (> (bytevector-length bytes) max-input-bytes) - (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-input-bytes)) - bytes) +(def input-read-chunk-bytes 65536) + +(def (chunks->bytevector chunks total) + (let ([out (make-bytevector total 0)]) + (let loop ([chunks (reverse chunks)] [offset 0]) + (unless (null? chunks) + (let* ([chunk (car chunks)] + [size (bytevector-length chunk)]) + (bytevector-copy! chunk 0 out offset size) + (loop (cdr chunks) (+ offset size))))) + out)) + +(def (reject-reported-oversize! who path port limit) + ;; file-length is a cheap preflight for regular files (including sparse + ;; files). It can fail for FIFOs and other streams, so the streaming limit + ;; below remains authoritative. + ;; Non-seekable inputs have no reported length; only that optional preflight + ;; is skipped. The authoritative limit remains the bounded streaming read. + (let ([reported-size (and (port-has-port-position? port) + (file-length port))]) + (when (and reported-size (> reported-size limit)) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit)))) + +(def (read-bounded-bytevector who path port limit) + (reject-reported-oversize! who path port limit) + (let ([buffer (make-bytevector input-read-chunk-bytes 0)]) + (let loop ([chunks '()] [total 0]) + ;; Once LIMIT bytes have been retained, read only one more byte. This + ;; detects a growing regular file or an oversized FIFO without ever + ;; retaining attacker-controlled data beyond the configured bound. + (let* ([remaining (+ (- limit total) 1)] + [want (min input-read-chunk-bytes remaining)] + [count (get-bytevector-n! port buffer 0 want)]) + (if (eof-object? count) + (chunks->bytevector chunks total) + (let ([next-total (+ total count)]) + (when (> next-total limit) + (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit)) + (let ([chunk (make-bytevector count 0)]) + (bytevector-copy! buffer 0 chunk 0 count) + (loop (cons chunk chunks) next-total)))))))) (def (read-file-bytevector path) (let ([port (open-file-input-port path)]) (dynamic-wind (lambda () (void)) (lambda () - (enforce-input-size 'read-file-bytevector path (get-bytevector-all port))) + (read-bounded-bytevector 'read-file-bytevector path port max-input-bytes)) (lambda () (close-port port))))) (def (bytevector-prefix? bv bytes) --- a/scripts/security-check.sh +++ b/scripts/security-check.sh @@ -138,4 +138,21 @@ if [[ -n "$host_fingerprint_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 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/tests/test-input-limit.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +repo_root=${REPO_ROOT:-$(pwd)} +tmp_dir=${TMPDIR:-/tmp}/jasm-input-limit-$$ +writer_pid= +reader_pid= +watchdog_pid= + +cleanup() { + if [ -n "$writer_pid" ]; then + kill "$writer_pid" 2>/dev/null || true + wait "$writer_pid" 2>/dev/null || true + fi + if [ -n "$reader_pid" ]; then + kill "$reader_pid" 2>/dev/null || true + wait "$reader_pid" 2>/dev/null || true + fi + if [ -n "$watchdog_pid" ]; then + kill "$watchdog_pid" 2>/dev/null || true + wait "$watchdog_pid" 2>/dev/null || true + fi + rm -rf "$tmp_dir" +} +trap cleanup EXIT INT TERM + +mkdir -p "$tmp_dir" + +# A sparse file exercises the pre-allocation file-length rejection. Creating +# it writes no payload blocks, so this remains cheap even on constrained CI. +dd if=/dev/zero of="$tmp_dir/sparse.bin" bs=1 count=0 seek=1073741824 2>/dev/null +if JASM_MAX_INPUT_BYTES=1024 "$repo_root/jasm" raw --arch x86-64 \ + --file "$tmp_dir/sparse.bin" >"$tmp_dir/sparse.out" 2>"$tmp_dir/sparse.err"; then + echo "sparse oversized input was accepted" >&2 + exit 1 +fi +grep -q 'input exceeds JASM_MAX_INPUT_BYTES' "$tmp_dir/sparse.err" + +# A FIFO has no reliable size. Keep producing until the reader closes it: a +# post-read limit check would wait forever, while a bounded reader rejects as +# soon as it observes limit+1 bytes. The watchdog makes a regression fail +# deterministically instead of hanging the test suite. +mkfifo "$tmp_dir/input.fifo" +(dd if=/dev/zero bs=65536 2>/dev/null >"$tmp_dir/input.fifo") & +writer_pid=$! +JASM_MAX_INPUT_BYTES=1024 "$repo_root/jasm" raw --arch x86-64 \ + --file "$tmp_dir/input.fifo" >"$tmp_dir/fifo.out" 2>"$tmp_dir/fifo.err" & +reader_pid=$! +( + sleep 5 + if kill "$reader_pid" 2>/dev/null; then + : >"$tmp_dir/fifo.timeout" + fi +) & +watchdog_pid=$! + +if wait "$reader_pid"; then + reader_status=0 +else + reader_status=$? +fi +reader_pid= +kill "$watchdog_pid" 2>/dev/null || true +wait "$watchdog_pid" 2>/dev/null || true +watchdog_pid= + +if [ -f "$tmp_dir/fifo.timeout" ]; then + echo "FIFO oversized input did not stop at the configured limit" >&2 + exit 1 +fi +if [ "$reader_status" -eq 0 ]; then + echo "FIFO oversized input was accepted" >&2 + exit 1 +fi +grep -q 'input exceeds JASM_MAX_INPUT_BYTES' "$tmp_dir/fifo.err" +wait "$writer_pid" 2>/dev/null || true +writer_pid= + +echo "input-limit-tests: ok"