Add shell security gate and sanitize evidence
ober
f12243301eb58cd32650f3f4c58671f430d34f98
--- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - name: Install Jerboa toolchain run: sh support/ensure-jerboa.sh "$JERBOA_VERSION" .jerboa/bin + - name: Security gate + run: make security + - name: Run unit tests run: make test --- a/.github/workflows/security-baseline.yml +++ b/.github/workflows/security-baseline.yml @@ -23,6 +23,9 @@ jobs: test -f .gitignore find . -maxdepth 1 -iname "README*" -type f | grep -q . + - name: Project security gate + run: make security + - name: High-confidence secret scan run: | set -eu --- a/.jerboa/security.json +++ b/.jerboa/security.json @@ -32,5 +32,15 @@ "timingEvidence": "dist/release-evidence/timing-evidence/status.txt" } }, + "required_gates": { + "local": [ + "make security", + "make verify", + "make release-evidence", + "make sbom", + "make reproducibility-report", + "make timing-evidence" + ] + }, "suppressions": [] } --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ SH_SPEC := python3 $(CURDIR)/test/run_spec.py BASH := /bin/bash JSH ?= $(if $(filter Darwin,$(UNAME_S)),./jsh-macos,./jsh) -.PHONY: ensure-jerboa-tools jerboa binary jsh jsh-macos macos run test test-binary adversarial-corpus audit \ +.PHONY: ensure-jerboa-tools jerboa binary jsh jsh-macos macos run security test test-binary adversarial-corpus audit \ sbom reproducibility-report timing-evidence verify release-evidence \ compat compat-smoke compat-tier0 compat-tier1 compat-tier2 compat-one compat-range compat-debug \ bench clean help @@ -92,6 +92,9 @@ jsh: binary run: ensure-jerboa-tools $(FFI_LIB) jerboa $(JERBUILD) exec --libdirs "$(LIBDIRS)" jsh.ss +security: + @REPO_ROOT="$(CURDIR)" sh tools/security-check.sh + test: ensure-jerboa-tools $(FFI_LIB) jerboa @echo "=== Running unit tests ===" $(JERBUILD) exec --libdirs "$(LIBDIRS)" test/test-jsh.ss @@ -129,7 +132,7 @@ timing-evidence: JSH_TIMING_EVIDENCE_DIR="$(TIMING_EVIDENCE_DIR)" \ sh tools/timing-evidence.sh -verify: test adversarial-corpus audit timing-evidence +verify: security test adversarial-corpus audit timing-evidence sbom reproducibility-report release-evidence: @rm -rf "$(RELEASE_EVIDENCE_DIR)" @@ -137,6 +140,8 @@ release-evidence: @echo "==> Collecting git status" @git status --short > "$(RELEASE_EVIDENCE_DIR)/git-status.txt" @git rev-parse HEAD > "$(RELEASE_EVIDENCE_DIR)/git-head.txt" 2>/dev/null || true + @echo "==> Running security gate" + @$(MAKE) security > "$(RELEASE_EVIDENCE_DIR)/security.log" 2>&1 @echo "==> Running unit tests" @$(MAKE) test > "$(RELEASE_EVIDENCE_DIR)/test.log" 2>&1 @echo "==> Running adversarial corpus" @@ -145,17 +150,20 @@ release-evidence: @$(MAKE) timing-evidence > "$(RELEASE_EVIDENCE_DIR)/timing-evidence.log" 2>&1 @rm -rf "$(RELEASE_EVIDENCE_DIR)/timing-evidence" @cp -R "$(TIMING_EVIDENCE_DIR)" "$(RELEASE_EVIDENCE_DIR)/timing-evidence" + @grep -q '^status=' "$(RELEASE_EVIDENCE_DIR)/timing-evidence/status.txt" @echo "==> Running native audit" @$(MAKE) audit > "$(RELEASE_EVIDENCE_DIR)/audit.log" 2>&1 @echo "==> Running reproducibility report" @$(MAKE) reproducibility-report > "$(RELEASE_EVIDENCE_DIR)/reproducibility.log" 2>&1 @rm -rf "$(RELEASE_EVIDENCE_DIR)/reproducibility" @cp -R "$(DIST_DIR)/reproducibility" "$(RELEASE_EVIDENCE_DIR)/reproducibility" + @grep -q '^status=match$$' "$(RELEASE_EVIDENCE_DIR)/reproducibility/report.txt" @echo "==> Generating SBOM" @$(MAKE) sbom > "$(RELEASE_EVIDENCE_DIR)/sbom.log" 2>&1 @rm -rf "$(RELEASE_EVIDENCE_DIR)/sbom" @cp -R "$(DIST_DIR)/sbom" "$(RELEASE_EVIDENCE_DIR)/sbom" @find *.ss jerboa-src support test tools docs -type f -print | LC_ALL=C sort | xargs shasum -a 256 > "$(RELEASE_EVIDENCE_DIR)/source-hashes.sha256" + @sh tools/sanitize-evidence.sh "$(RELEASE_EVIDENCE_DIR)" "$(DIST_DIR)/sbom" "$(DIST_DIR)/reproducibility" "$(TIMING_EVIDENCE_DIR)" @rm -rf src jsh-src @echo "Release evidence written to $(RELEASE_EVIDENCE_DIR)" @@ -219,6 +227,7 @@ help: @echo " make jsh Build and copy the native binary to ./jsh" @echo "" @echo "Test:" + @echo " make security Release security metadata and secret scan" @echo " make test Unit tests" @echo " make test-binary Binary smoke tests" @echo " make audit Native FFI symbol/dependency audit" --- a/SECURITY.md +++ b/SECURITY.md @@ -3,8 +3,8 @@ `jerboa-shell` is an experimental command shell. It intentionally parses and executes user-provided shell language, starts host processes, manipulates file descriptors, writes history, expands globs, performs redirection, and runs -startup files. Do not advertise production support until the release gates in -`~/Release-plan.md` and `~/mine/jerboa-production-readiness.md` are complete. +startup files. Do not advertise production support until the release plan and +production-readiness tracker are complete. ## Security Posture @@ -21,8 +21,11 @@ startup files. Do not advertise production support until the release gates in - Entrypoints call `ffi_ensure_std_fds` before Scheme initialization so fds 0/1/2 are repaired even in unusual launcher contexts. - History is plaintext by default and is chmoded to `0600` after writes. +- `make security` validates release metadata, required security markers, + generated-artifact exclusions, and high-confidence secret scans. - `make release-evidence` records unit tests, native FFI audit output, SBOM - manifests, generated-source reproducibility, and FFI-shim reproducibility. + manifests, generated-source reproducibility, FFI-shim reproducibility, and + sanitized host-neutral release evidence. ## Sensitive Data @@ -34,6 +37,8 @@ logs, or generated compatibility reports from private workloads. Local release candidates must pass: +- `make security` +- `make verify` - `make test` - `make audit` - `make timing-evidence` --- a/docs/ffi-boundary.md +++ b/docs/ffi-boundary.md @@ -17,7 +17,7 @@ limit operations. - `ffi_ensure_std_fds` opens `/dev/null` onto fd 0/1/2 when any standard descriptor is closed. - `make release-evidence` records native FFI audit output, FFI shim hashes, - linkage/load-command output, and repeated FFI shim build hashes. + sanitized linkage/load-command output, and repeated FFI shim build hashes. ## Ownership And Lifetime --- a/docs/release-evidence.md +++ b/docs/release-evidence.md @@ -5,6 +5,7 @@ The evidence bundle contains: +- security gate output; - unit-test output; - deterministic adversarial parser/redirection/job-control corpus output; - timing/fd lifecycle target proof status under `timing-evidence/`; @@ -16,9 +17,13 @@ The evidence bundle contains: system linker and is required for `dlopen()`, so the report records both full hashes and hashes normalized only for the UUID load-command payload. -By default the reproducibility report does not rebuild the full `jsh-macos` -standalone binary. Set `JERBOA_SHELL_REPRO_BINARY=1` when a release job needs to -compare the deployable binary as well. +`make release-evidence` fails unless the copied reproducibility report records +`status=match`. By default the reproducibility report does not rebuild the full +`jsh-macos` standalone binary. Set `JERBOA_SHELL_REPRO_BINARY=1` when a release +job needs to compare the deployable binary as well. + +Evidence text is sanitized before archival so private checkout paths, SSH clone +URLs, and host-identifying names are not preserved in release bundles. adversarial_corpus_status: documented adversarial_corpus_cases_minimum: 512 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -34,8 +34,9 @@ Scheme FFI declarations. - `ffi_ensure_std_fds` repairs closed standard descriptors before the Scheme runtime starts. -- Release evidence records unit tests, native FFI audit output, SBOM manifests, - generated-source reproducibility, and FFI-shim reproducibility. +- Release evidence records the security gate, unit tests, native FFI audit + output, SBOM manifests, generated-source reproducibility, FFI-shim + reproducibility, and sanitized host-neutral evidence. ## Non-Goals new file mode 100755 --- /dev/null +++ b/tools/sanitize-evidence.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +[ "$#" -gt 0 ] || exit 0 + +for path in "$@"; do + [ -e "$path" ] || continue + find "$path" -type f -print | while IFS= read -r file; do + if grep -Iq . "$file"; then + tmp=$file.sanitize.$$ + sed -E \ + -e 's#/Users/[^[:space:]"]+#<redacted-path>#g' \ + -e 's#~/mine(/[^[:space:]"]*)?#<redacted-path>#g' \ + -e 's#git@[^[:space:]"]+#<redacted-ssh-remote>#g' \ + -e 's#users-MacBook-Pro#<redacted-host>#g' \ + -e 's#uname=Darwin [^[:space:]]+ #uname=Darwin #g' \ + "$file" > "$tmp" + mv "$tmp" "$file" + fi + done +done --- a/tools/sbom.sh +++ b/tools/sbom.sh @@ -7,49 +7,53 @@ out_dir=${SBOM_DIR:-"$dist_dir/sbom"} ffi_lib=${FFI_LIB:-libjsh-ffi.dylib} mkdir -p "$out_dir" +hash_file_with_label() { + file=$1 + label=$2 + if command -v sha256sum >/dev/null 2>&1; then + hash=$(sha256sum "$file" | awk '{print $1}') + else + hash=$(shasum -a 256 "$file" | awk '{print $1}') + fi + printf '%s %s\n' "$hash" "$label" +} + hash_manifest() { dir=$1 out=$2 if [ -d "$dir" ]; then - find "$dir" -type f \ + rel_dir=${dir#"$repo_root"/} + (cd "$repo_root" && find "$rel_dir" -type f \ ! -path '*/.git/*' \ ! -path '*/dist/*' \ ! -path '*/.jerboa/*' \ ! -path '*/_vendor/*' \ -print | LC_ALL=C sort | while IFS= read -r file; do - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$file" - else - shasum -a 256 "$file" - fi - done > "$out" + hash_file_with_label "$file" "$file" + done) > "$out" else : > "$out" fi } { - echo "repo=$repo_root" + echo "repo=jerboa-shell" echo "version=$(tr -d '[:space:]' < "$repo_root/VERSION" 2>/dev/null || true)" echo "generated_at_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - echo "uname=$(uname -a)" + echo "uname=$(uname -srm)" echo "cc=$(${CC:-cc} --version 2>/dev/null | sed -n '1p' || true)" echo "python=$(python3 --version 2>/dev/null || true)" - echo "jerbuild=${JERBUILD:-jerbuild}" + echo "jerbuild=$([ -n "${JERBUILD:-}" ] && basename "${JERBUILD:-jerbuild}" || printf '%s' jerbuild)" "${JERBUILD:-jerbuild}" --version 2>/dev/null || true } > "$out_dir/build-environment.txt" if [ -f "$repo_root/$ffi_lib" ]; then - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$repo_root/$ffi_lib" > "$out_dir/ffi-shim.sha256" - else - shasum -a 256 "$repo_root/$ffi_lib" > "$out_dir/ffi-shim.sha256" - fi + hash_file_with_label "$repo_root/$ffi_lib" "$ffi_lib" > "$out_dir/ffi-shim.sha256" if command -v otool >/dev/null 2>&1; then - otool -L "$repo_root/$ffi_lib" > "$out_dir/ffi-shim-linkage.txt" - otool -l "$repo_root/$ffi_lib" > "$out_dir/ffi-shim-load-commands.txt" + (cd "$repo_root" && otool -L "$ffi_lib") > "$out_dir/ffi-shim-linkage.txt" + (cd "$repo_root" && otool -l "$ffi_lib") > "$out_dir/ffi-shim-load-commands.txt" elif command -v ldd >/dev/null 2>&1; then - ldd "$repo_root/$ffi_lib" > "$out_dir/ffi-shim-linkage.txt" + (cd "$repo_root" && ldd "$ffi_lib") > "$out_dir/ffi-shim-linkage.txt" else echo "No native dependency inspector available." > "$out_dir/ffi-shim-linkage.txt" fi @@ -63,14 +67,10 @@ hash_manifest "$repo_root/support" "$out_dir/support.sha256" hash_manifest "$repo_root/test" "$out_dir/test-harness.sha256" hash_manifest "$repo_root/docs" "$out_dir/docs.sha256" -find "$repo_root" -maxdepth 1 -type f \ +(cd "$repo_root" && find . -maxdepth 1 -type f \ \( -name '*.ss' -o -name 'Makefile' -o -name 'README.md' -o -name 'SECURITY.md' -o -name 'VERSION' \) \ - -print | LC_ALL=C sort | while IFS= read -r file; do - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$file" - else - shasum -a 256 "$file" - fi - done > "$out_dir/source-modules.sha256" + -print | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r file; do + hash_file_with_label "$file" "$file" + done) > "$out_dir/source-modules.sha256" echo "status=generated" new file mode 100755 --- /dev/null +++ b/tools/security-check.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -eu + +repo_root=${REPO_ROOT:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)} +cd "$repo_root" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_file() { + [ -f "$1" ] || fail "missing required file: $1" +} + +require_marker() { + marker=$1 + shift + found=0 + for file in "$@"; do + if [ -f "$file" ] && grep -Fq "$marker" "$file"; then + found=1 + fi + done + [ "$found" = 1 ] || fail "missing release marker: $marker" +} + +require_file LICENSE +require_file README.md +require_file SECURITY.md +require_file .gitignore +require_file .jerboa/security.json +require_file docs/threat-model.md +require_file docs/ffi-boundary.md +require_file docs/release-evidence.md +require_file tools/timing-evidence.sh +require_file tools/sbom.sh +require_file tools/reproducibility-report.sh +require_file tools/sanitize-evidence.sh +require_file ffi-shim.c +require_file support/adversarial-corpus-evidence.ss + +python3 -m json.tool .jerboa/security.json >/dev/null + +require_marker "not a sandbox" SECURITY.md docs/threat-model.md +require_marker "adversarial_corpus_status: documented" docs/release-evidence.md +require_marker "adversarial_corpus_cases_minimum: 512" docs/release-evidence.md +require_marker "JSH_TARGET_TIMING_PROOF_FILE" SECURITY.md docs/release-evidence.md .jerboa/security.json +require_marker "sensitive_artifact_policy=no-command-logs-history-env-or-private-paths" docs/release-evidence.md tools/timing-evidence.sh +require_marker "Full standalone binary reproducibility" docs/release-evidence.md docs/threat-model.md +require_marker "ffi_ensure_std_fds" ffi-shim.c docs/ffi-boundary.md + +tracked_generated=$(git ls-files 'src/**' 'jsh-src/**' 'dist/**' 'jsh' 'jsh-macos' '*.so' '*.dylib' '*.wpo' '*.o' 2>/dev/null || true) +[ -z "$tracked_generated" ] || fail "generated artifacts are tracked: $tracked_generated" + +secret_pattern='(BEGIN (RSA|OPENSSH|EC|DSA|PRIVATE) KEY|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-(ant-api03|proj|svcacct)-[A-Za-z0-9_-]{30,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})' +secret_hits=$(git grep -n -I -E "$secret_pattern" -- . || true) +[ -z "$secret_hits" ] || { + printf '%s\n' "$secret_hits" >&2 + fail "high-confidence secret pattern found" +} + +doc_private_hits=$(git grep -n -I -E '(/Users/|~/mine|git@|users-MacBook-Pro)' -- README.md SECURITY.md docs .github 2>/dev/null || true) +[ -z "$doc_private_hits" ] || { + printf '%s\n' "$doc_private_hits" >&2 + fail "public docs or CI contain private path/host/SSH material" +} + +echo "security_status=pass" --- a/tools/timing-evidence.sh +++ b/tools/timing-evidence.sh @@ -5,6 +5,7 @@ ROOT=${REPO_ROOT:-$(pwd)} OUT=${JSH_TIMING_EVIDENCE_DIR:-${DIST_DIR:-$ROOT/dist}/timing-evidence} PROOF_FILE=${JSH_TARGET_TIMING_PROOF_FILE:-} REQUIRE_PROOF=${JSH_REQUIRE_TARGET_TIMING_PROOF:-0} +MAX_PROOF_BYTES=${JSH_TARGET_TIMING_PROOF_MAX_BYTES:-65536} PRIVATE_PATTERN='(/Users/|/home/[^[:space:]\"]+/(mine|src|work)|~/mine|git@|\.local|\.lan|\.corp|\.internal|10\.[0-9]{1,3}\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)' SECRET_PATTERN='(BEGIN (RSA|OPENSSH|EC|DSA|PRIVATE) KEY|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-(ant-api03|proj|svcacct)-[A-Za-z0-9_-]{30,}|AKIA[0-9A-Z]{16})' @@ -25,7 +26,7 @@ sensitive_artifact_policy=no-command-logs-history-env-or-private-paths { printf 'generated_at_utc=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - printf 'uname=%s\n' "$(uname -a)" + printf 'uname=%s\n' "$(uname -srm)" printf 'git_status:\n' git status --short 2>/dev/null || true } > "$OUT/local-host.txt" @@ -43,6 +44,9 @@ if [ -n "$PROOF_FILE" ] || [ "$REQUIRE_PROOF" = "1" ]; then if [ -z "$PROOF_FILE" ] || [ ! -r "$PROOF_FILE" ]; then status=blocked-target-timing-proof target_timing_proof_status=missing + elif [ "$(wc -c < "$PROOF_FILE" | tr -d ' ')" -gt "$MAX_PROOF_BYTES" ]; then + status=blocked-target-timing-proof + target_timing_proof_status=too-large elif grep -Eiq "$PRIVATE_PATTERN|$SECRET_PATTERN" "$PROOF_FILE"; then status=blocked-target-timing-proof target_timing_proof_status=sensitive-material-rejected