Harden Edge release evidence privacy
ober
c1b08e45f41aa718ba1642cfbef016ddd65b3ab4
--- a/.jerboa/security.json +++ b/.jerboa/security.json @@ -20,7 +20,10 @@ "linuxRuntime": "current-smoke-recorded-or-target-proof-required", "sustainedLoad": "release-host-sustained-recorded-or-target-proof-required", "proofFiles": ["JEDGE_TARGET_RUNTIME_PROOF_FILE", "JEDGE_TARGET_LOAD_PROOF_FILE"], - "failClosedProofFlags": ["JEDGE_REQUIRE_TARGET_RUNTIME_PROOF", "JEDGE_REQUIRE_TARGET_LOAD_PROOF"] + "failClosedProofFlags": ["JEDGE_REQUIRE_TARGET_RUNTIME_PROOF", "JEDGE_REQUIRE_TARGET_LOAD_PROOF"], + "evidenceSanitizer": "scripts/sanitize-evidence.sh", + "artifactArchivePolicy": "hashes-only-for-reproducibility", + "forbiddenEvidenceMaterial": ["host-private-paths", "raw-git-remotes", "private-keys", "credential-shaped-material"] }, "eval": { "stringEval": "deny", "bareRead": "deny", "allowReadEval": false }, "auth": { "rejectUnsignedWebhooks": true, "allowUnsignedOnlyWithExplicitDevFlag": "EDGE_ALLOW_UNSIGNED" } --- a/Makefile +++ b/Makefile @@ -2,7 +2,8 @@ # crate. The Makefile copies that native crate into .jerboa/jerboa-native-rs # before building so the daemon's native archive is repo-local and feature # isolated. -JERBUILD ?= jerbuild +ADJACENT_JERBUILD := $(CURDIR)/../jerboa/dist/jerbuild +JERBUILD ?= $(if $(wildcard $(ADJACENT_JERBUILD)),$(ADJACENT_JERBUILD),jerbuild) JH := $(shell $(JERBUILD) --jerboa-home 2>/dev/null) ifeq ($(JH),) $(error jerbuild not found on PATH (or '$(JERBUILD) --jerboa-home' failed). Install jerbuild, or set JERBUILD=/path/to/jerbuild) --- a/docs/release-evidence.md +++ b/docs/release-evidence.md @@ -5,21 +5,29 @@ The directory includes: - git commit, status, and diff summary, -- build environment and native feature summary, +- host-neutral build environment and native feature summary, - security, audit, import-check, and smoke-test logs, - Cargo metadata for the bundled Jerboa native crate when available, - SBOM/dependency manifests for source, support scripts, vendored jsqlite, the repo-local no-default `crypto,wasm` native crate copy, binary linkage, and native artifact hashes, - repeated clean-build reproducibility reports for the standalone binary, - generated FFI symbol list, native archive, and source manifest, + generated FFI symbol list, native archive, and source manifest. The + reproducibility archive is hash-only; scratch build logs and copied binaries + are not preserved, - `soak/` with Linux runtime smoke, sustained request-load status, optional target proof files, and SHA-256 hashes of attached proof files. Local non-Linux evidence records the Linux runtime smoke and load gates as blocked instead of treating the macOS smoke skip as production-ready evidence, -- binary linkage information, +- sanitized binary linkage information, - SHA-256 hashes for the binary and source files. +Evidence files are sanitized before publication. Local host paths, private Git +remote syntax, and machine-specific hostnames are replaced with neutral markers. +Target proof files are rejected before copy when they are over +`JEDGE_TARGET_PROOF_MAX_BYTES` bytes or contain private host paths, raw +`git@` remotes, private-key blocks, or credential-shaped material. + Release evidence is generated from the same gates used by CI: ```sh @@ -67,7 +75,10 @@ The runtime proof must contain `linux_runtime_status=current-smoke-recorded`. The load proof must contain `load_status=release-host-sustained-recorded`. Supplied proof files are copied into `dist/soak/` and hashed. Missing, empty, or marker-mismatched required proof files fail closed with -`status=blocked-target-proof`. +`status=blocked-target-proof`. Oversized proof files fail closed with +`blocked-too-large`, private local material fails closed with +`blocked-private-material`, and key/token-shaped material fails closed with +`blocked-sensitive-material`. The target writes evidence before returning failure. A nonzero exit with `release_evidence_status=blocked` is a release blocker, not an incomplete --- a/scripts/release-evidence.sh +++ b/scripts/release-evidence.sh @@ -7,6 +7,7 @@ sbom_dir=${SBOM_DIR:-"$repo_root/dist/sbom"} repro_dir=${REPRO_DIR:-"$repo_root/dist/reproducibility"} soak_dir=${SOAK_DIR:-"$repo_root/dist/soak"} make_cmd=${MAKE:-make} +sanitizer="$repo_root/scripts/sanitize-evidence.sh" case "$evidence_dir" in /*) ;; @@ -28,6 +29,13 @@ esac rm -rf "$evidence_dir" mkdir -p "$evidence_dir" +sanitize_all() { + if [ -x "$sanitizer" ]; then + "$sanitizer" "$evidence_dir" "$sbom_dir" "$repro_dir" "$soak_dir" + fi +} +trap sanitize_all EXIT + cd "$repo_root" { @@ -36,13 +44,21 @@ cd "$repo_root" git status --short > "$evidence_dir/git-status.txt" 2>/dev/null || true git diff --stat > "$evidence_dir/diff-stat.txt" 2>/dev/null || true { - printf 'JERBUILD=%s\n' "${JERBUILD:-jerbuild}" - printf 'JERBOA_HOME=%s\n' "${JERBOA_HOME:-}" - printf 'NATIVE_DIR=%s\n' "${NATIVE_DIR:-}" - printf 'NATIVE_A=%s\n' "${NATIVE_A:-}" + printf 'jerbuild_command_status=configured\n' + if [ -n "${JERBOA_HOME:-}" ]; then printf 'jerboa_home_status=present\n'; else printf 'jerboa_home_status=missing\n'; fi + case "${NATIVE_DIR:-}" in + "$repo_root"/.jerboa/*) printf 'native_dir_status=repo-local\n' ;; + '') printf 'native_dir_status=missing\n' ;; + *) printf 'native_dir_status=external\n' ;; + esac + if [ -n "${NATIVE_A:-}" ] && [ -f "${NATIVE_A:-}" ]; then printf 'native_archive_status=present\n'; else printf 'native_archive_status=missing\n'; fi printf 'NATIVE_FEATURES=%s\n' "${NATIVE_FEATURES:-crypto,wasm}" printf 'NATIVE_NO_DEFAULT_FEATURES=%s\n' "${NATIVE_NO_DEFAULT_FEATURES:-1}" - printf 'uname=%s\n' "$(uname -a)" + printf 'os=%s\n' "$(uname -s 2>/dev/null || printf unknown)" + printf 'kernel_release=%s\n' "$(uname -r 2>/dev/null || printf unknown)" + printf 'machine=%s\n' "$(uname -m 2>/dev/null || printf unknown)" + printf 'jerbuild_version=\n' + "${JERBUILD:-jerbuild}" --version 2>&1 || true } > "$evidence_dir/build-env.txt" status=0 @@ -72,11 +88,18 @@ rm -rf "$evidence_dir/sbom" "$evidence_dir/reproducibility" "$evidence_dir/soak" [ -d "$sbom_dir" ] && cp -R "$sbom_dir" "$evidence_dir/sbom" [ -d "$repro_dir" ] && cp -R "$repro_dir" "$evidence_dir/reproducibility" [ -d "$soak_dir" ] && cp -R "$soak_dir" "$evidence_dir/soak" +sanitize_all if [ -f "$evidence_dir/reproducibility/report.txt" ]; then if ! grep -q '^status=match$' "$evidence_dir/reproducibility/report.txt"; then status=1 fi + if ! grep -q '^artifact_archive_status=hashes-only$' "$evidence_dir/reproducibility/report.txt"; then + status=1 + fi + if ! grep -q '^scratch_build_logs_status=not-archived$' "$evidence_dir/reproducibility/report.txt"; then + status=1 + fi fi if [ -f "$evidence_dir/sbom/dependency-audit.status" ]; then --- a/scripts/reproducibility-report.sh +++ b/scripts/reproducibility-report.sh @@ -7,6 +7,10 @@ make_cmd=${MAKE:-make} jerbuild=${JERBUILD:-jerbuild} bin=${BIN:-jerboa-edge} native_a=${NATIVE_A:-} +tmp_parent=${TMPDIR:-/tmp} +tmp_dir=$(mktemp -d "$tmp_parent/jedge-repro-XXXXXX") +obj_dir="$tmp_dir/jerbuild-binary-fixed" +trap 'rm -rf "$tmp_dir"' EXIT case "$out_dir" in /*) ;; @@ -18,6 +22,12 @@ mkdir -p "$out_dir" cd "$repo_root" +hash_file_as() { + hash_path=$1 + hash_label=$2 + shasum -a 256 "$hash_path" | awk -v label="$hash_label" '{ print $1 " " label }' +} + hash_sources() { dest=$1 find . \ @@ -36,19 +46,20 @@ hash_sources() { build_once() { label=$1 - "$make_cmd" --no-print-directory clean-generated > "$out_dir/$label-clean.log" 2>&1 || true - JERBUILD_BINARY_OBJ_DIR="$out_dir/jerbuild-binary-fixed" \ + "$make_cmd" --no-print-directory clean-generated > "$tmp_dir/$label-clean.log" 2>&1 || true + rm -rf "$obj_dir" + mkdir -p "$obj_dir" + JERBUILD_BINARY_OBJ_DIR="$obj_dir" \ JERBUILD="$jerbuild" "$make_cmd" --no-print-directory binary \ - > "$out_dir/$label-build.log" 2>&1 + > "$tmp_dir/$label-build.log" 2>&1 if [ -f "$bin" ]; then - shasum -a 256 "$bin" > "$out_dir/$label-binary.sha256" - cp "$bin" "$out_dir/$label-$bin" + hash_file_as "$bin" "$bin" > "$out_dir/$label-binary.sha256" fi if [ -f support/ffi-symbols.gen ]; then - shasum -a 256 support/ffi-symbols.gen > "$out_dir/$label-ffi-symbols.sha256" + hash_file_as support/ffi-symbols.gen support/ffi-symbols.gen > "$out_dir/$label-ffi-symbols.sha256" fi if [ -n "$native_a" ] && [ -f "$native_a" ]; then - shasum -a 256 "$native_a" > "$out_dir/$label-native-archive.sha256" + hash_file_as "$native_a" native/libjerboa_native.a > "$out_dir/$label-native-archive.sha256" fi hash_sources "$out_dir/$label-source.sha256" } @@ -97,6 +108,9 @@ for item in "$binary_status" "$ffi_symbols_status" "$native_archive_status" "$so done { + printf 'artifact_archive_status=hashes-only\n' + printf 'scratch_build_logs_status=not-archived\n' + printf 'jerbuild_binary_obj_dir_status=ephemeral\n' printf 'binary_status=%s\n' "$binary_status" printf 'ffi_symbols_status=%s\n' "$ffi_symbols_status" printf 'native_archive_status=%s\n' "$native_archive_status" new file mode 100755 --- /dev/null +++ b/scripts/sanitize-evidence.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +if [ "$#" -eq 0 ]; then + set -- dist/release-evidence +fi + +for evidence_dir in "$@"; do + if [ ! -d "$evidence_dir" ]; then + continue + fi + + find "$evidence_dir" -type f -print | while IFS= read -r file; do + perl -0pi -e ' + s#/Users/[^[:space:]\x22\x27()]+#<local-path>#g; + if (defined $ENV{HOME} && length $ENV{HOME}) { + my $home = quotemeta($ENV{HOME}); + s#$home#<home>#g; + } + s#~/mine#<local-path>#g; + s#\$\((HOME)\)/mine#<local-path>#g; + s#users-MacBook-Pro#<host>#g; + s#git\@#git-at-#g; + ' "$file" + done +done --- a/scripts/sbom.sh +++ b/scripts/sbom.sh @@ -10,6 +10,7 @@ native_a=${NATIVE_A:-} native_features=${NATIVE_FEATURES:-crypto,wasm} native_no_default_features=${NATIVE_NO_DEFAULT_FEATURES:-1} bin=${BIN:-jerboa-edge} +sanitizer="$repo_root/scripts/sanitize-evidence.sh" case "$out_dir" in /*) ;; @@ -18,21 +19,31 @@ esac rm -rf "$out_dir" mkdir -p "$out_dir" +if [ -x "$sanitizer" ]; then + trap '"$sanitizer" "$out_dir"' EXIT +fi { - printf 'repo=%s\n' "$repo_root" - printf 'jerbuild=%s\n' "$jerbuild" + printf 'repo=jerboa-edge\n' + printf 'repo_root_status=present\n' + printf 'jerbuild_command_status=configured\n' "$jerbuild" --version 2>/dev/null || true - printf 'jerboa_home=%s\n' "$jerboa_home" - printf 'native_dir=%s\n' "$native_dir" - printf 'native_archive=%s\n' "$native_a" + if [ -n "$jerboa_home" ]; then printf 'jerboa_home_status=present\n'; else printf 'jerboa_home_status=missing\n'; fi + case "$native_dir" in + "$repo_root"/.jerboa/*) printf 'native_dir_status=repo-local\n' ;; + '') printf 'native_dir_status=missing\n' ;; + *) printf 'native_dir_status=external\n' ;; + esac + if [ -n "$native_a" ] && [ -f "$native_a" ]; then printf 'native_archive_status=present\n'; else printf 'native_archive_status=missing\n'; fi printf 'native_features=%s\n' "$native_features" printf 'native_no_default_features=%s\n' "$native_no_default_features" case "$native_dir" in "$repo_root"/.jerboa/*) printf 'native_target_isolation_status=repo-local\n' ;; *) printf 'native_target_isolation_status=shared-or-external\n' ;; esac - printf 'uname=%s\n' "$(uname -a)" + printf 'os=%s\n' "$(uname -s 2>/dev/null || printf unknown)" + printf 'kernel_release=%s\n' "$(uname -r 2>/dev/null || printf unknown)" + printf 'machine=%s\n' "$(uname -m 2>/dev/null || printf unknown)" cc --version 2>/dev/null | head -1 || cc -v 2>&1 | head -1 || true } > "$out_dir/build-environment.txt" @@ -56,17 +67,17 @@ fi hash_set() { label=$1 shift - { + (cd "$repo_root" && { for path in "$@"; do - if [ -e "$repo_root/$path" ]; then - find "$repo_root/$path" \ + if [ -e "$path" ]; then + find "$path" \ \( -path '*/.git' -o -path '*/target' \) -prune -o \ -type f -print fi done } | LC_ALL=C sort | while IFS= read -r file; do shasum -a 256 "$file" - done > "$out_dir/$label.sha256" + done) > "$out_dir/$label.sha256" } hash_set top-level .gitignore .jerbuild Makefile README.md SECURITY.md LICENSE edge.ss @@ -77,17 +88,17 @@ hash_set workflows .github hash_set jsqlite-source vendor/jsqlite/src if [ -f "$repo_root/$bin" ]; then - shasum -a 256 "$repo_root/$bin" > "$out_dir/binary.sha256" + (cd "$repo_root" && shasum -a 256 "$bin") > "$out_dir/binary.sha256" if command -v otool >/dev/null 2>&1; then - otool -L "$repo_root/$bin" > "$out_dir/binary.linkage" 2>&1 || true + otool -L "$repo_root/$bin" 2>&1 | sed "1s#.*#${bin}:#" > "$out_dir/binary.linkage" || true elif command -v ldd >/dev/null 2>&1; then ldd "$repo_root/$bin" > "$out_dir/binary.linkage" 2>&1 || true fi fi if [ -n "$native_a" ] && [ -f "$native_a" ]; then - shasum -a 256 "$native_a" > "$out_dir/native-archive.sha256" - file "$native_a" > "$out_dir/native-archive.file" 2>&1 || true + shasum -a 256 "$native_a" | awk '{ print $1 " native/libjerboa_native.a" }' > "$out_dir/native-archive.sha256" + file "$native_a" 2>&1 | sed 's#^[^:]*:#native/libjerboa_native.a:#' > "$out_dir/native-archive.file" || true fi { --- a/scripts/security-check.sh +++ b/scripts/security-check.sh @@ -15,6 +15,7 @@ required=( scripts/security-check.sh scripts/sbom.sh scripts/reproducibility-report.sh + scripts/sanitize-evidence.sh scripts/soak-evidence.sh scripts/release-evidence.sh support/import-check.ss @@ -112,6 +113,19 @@ grep -q 'release-host-sustained-recorded' scripts/soak-evidence.sh grep -q 'load_status=release-host-sustained-recorded' docs/release-evidence.md grep -q 'status=blocked-target-proof' docs/deployment-security.md grep -q 'status=blocked-target-proof' docs/threat-model.md +[[ -x scripts/sanitize-evidence.sh ]] +grep -q 'sanitize-evidence.sh' scripts/release-evidence.sh +grep -q 'sanitize-evidence.sh' scripts/sbom.sh +grep -q 'sanitize-evidence.sh' scripts/soak-evidence.sh +grep -q 'artifact_archive_status=hashes-only' scripts/reproducibility-report.sh +grep -q 'scratch_build_logs_status=not-archived' scripts/reproducibility-report.sh +grep -q 'blocked-private-material' scripts/soak-evidence.sh +grep -q 'blocked-sensitive-material' scripts/soak-evidence.sh +grep -q 'JEDGE_TARGET_PROOF_MAX_BYTES' docs/release-evidence.md +if grep -R -n 'uname -a' Makefile scripts docs SECURITY.md .jerboa/security.json | grep -v '^scripts/security-check.sh:'; then + echo "raw uname -a must not be recorded in release evidence; use host-neutral os/kernel/machine fields." >&2 + exit 1 +fi 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})' if command -v rg >/dev/null 2>&1; then --- a/scripts/soak-evidence.sh +++ b/scripts/soak-evidence.sh @@ -13,6 +13,8 @@ target_load_proof_file=${JEDGE_TARGET_LOAD_PROOF_FILE:-} production_min_requests=${JEDGE_PRODUCTION_SOAK_MIN_REQUESTS:-10000} request_timeout_seconds=${JEDGE_LOAD_REQUEST_TIMEOUT_SECONDS:-2} smoke_port=${SMOKE_PORT:-18080} +max_proof_bytes=${JEDGE_TARGET_PROOF_MAX_BYTES:-65536} +sanitizer="$repo_root/scripts/sanitize-evidence.sh" case "$out_dir" in /*) ;; @@ -21,6 +23,28 @@ esac rm -rf "$out_dir" mkdir -p "$out_dir" +load_pid= + +cleanup_load() { + if [ -n "$load_pid" ]; then + kill "$load_pid" >/dev/null 2>&1 || true + wait "$load_pid" >/dev/null 2>&1 || true + load_pid= + fi +} + +sanitize_out() { + if [ -x "$sanitizer" ]; then + "$sanitizer" "$out_dir" + fi +} + +finish() { + cleanup_load + sanitize_out +} + +trap finish EXIT INT TERM case "$production_min_requests" in ''|*[!0-9]*) @@ -42,11 +66,31 @@ if [ "$request_timeout_seconds" -lt 1 ]; then echo "JEDGE_LOAD_REQUEST_TIMEOUT_SECONDS must be a positive integer" >&2 exit 1 fi +case "$max_proof_bytes" in + ''|*[!0-9]*) + echo "JEDGE_TARGET_PROOF_MAX_BYTES must be a positive integer" >&2 + exit 1 + ;; +esac +if [ "$max_proof_bytes" -lt 1 ]; then + echo "JEDGE_TARGET_PROOF_MAX_BYTES must be a positive integer" >&2 + exit 1 +fi proof_status_file="$out_dir/proof-status.txt" : > "$proof_status_file" proof_blocked=0 +proof_contains() { + pattern=$1 + file=$2 + if command -v rg >/dev/null 2>&1; then + rg -q -I -e "$pattern" "$file" 2>/dev/null + else + grep -E -q "$pattern" "$file" 2>/dev/null + fi +} + copy_proof() { label=$1 source_path=$2 @@ -76,6 +120,26 @@ copy_proof() { return 0 fi + proof_size=$(wc -c < "$proof_path" | tr -d ' ') + if [ "$proof_size" -gt "$max_proof_bytes" ]; then + printf '%s=blocked-too-large\n' "$status_key" >> "$proof_status_file" + proof_blocked=1 + return 0 + fi + + 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}|edge[_-]?(secret|token|password)[[:space:]]*[:=]|webhook[_-]?secret[[:space:]]*[:=])' + private_pattern='(/Users/|~/mine|\$\(HOME\)/mine|git@|users-MacBook-Pro|uname[[:space:]]+-a)' + if proof_contains "$secret_pattern" "$proof_path"; then + printf '%s=blocked-sensitive-material\n' "$status_key" >> "$proof_status_file" + proof_blocked=1 + return 0 + fi + if proof_contains "$private_pattern" "$proof_path"; then + printf '%s=blocked-private-material\n' "$status_key" >> "$proof_status_file" + proof_blocked=1 + return 0 + fi + if ! grep -q "$required_marker" "$proof_path"; then printf '%s=blocked-marker-missing\n' "$status_key" >> "$proof_status_file" proof_blocked=1 @@ -123,6 +187,7 @@ write_status() { printf 'production_load_status=%s\n' "$4" printf 'production_min_requests=%s\n' "$production_min_requests" printf 'request_timeout_seconds=%s\n' "$request_timeout_seconds" + printf 'target_proof_max_bytes=%s\n' "$max_proof_bytes" cat "$proof_status_file" shift 4 while [ "$#" -gt 0 ]; do @@ -132,15 +197,6 @@ write_status() { } > "$out_dir/status.txt" } -load_pid= -cleanup_load() { - if [ -n "$load_pid" ]; then - kill "$load_pid" >/dev/null 2>&1 || true - wait "$load_pid" >/dev/null 2>&1 || true - load_pid= - fi -} - run_linux_production_load() { if ! command -v curl >/dev/null 2>&1; then printf 'curl_missing=1\n' > "$out_dir/current-load.log" @@ -153,7 +209,6 @@ run_linux_production_load() { EDGE_PORT="$smoke_port" EDGE_ALLOW_UNSIGNED=1 "$repo_root/jerboa-edge" \ > "$out_dir/load-daemon.log" 2>&1 & load_pid=$! - trap cleanup_load EXIT INT TERM ready=0 i=0 @@ -174,7 +229,6 @@ run_linux_production_load() { printf 'requests_failed=1\n' } > "$out_dir/current-load.log" cleanup_load - trap - EXIT INT TERM return 1 fi @@ -203,7 +257,6 @@ run_linux_production_load() { done cleanup_load - trap - EXIT INT TERM if [ "$failed" -eq 0 ]; then load_result=release-host-sustained-recorded @@ -227,12 +280,14 @@ run_linux_production_load() { printf 'requested_production_load=%s\n' "$run_production_load" printf 'production_min_requests=%s\n' "$production_min_requests" printf 'request_timeout_seconds=%s\n' "$request_timeout_seconds" - printf 'jerbuild_command=%s\n' "${JERBUILD:-jerbuild}" + printf 'jerbuild_command_status=configured\n' printf 'smoke_port=%s\n' "$smoke_port" - printf 'target_runtime_proof_file=%s\n' "$target_runtime_proof_file" - printf 'target_load_proof_file=%s\n' "$target_load_proof_file" - printf 'uname=%s\n' "$(uname -a)" - printf 'curl=%s\n' "$(command -v curl || true)" + if [ -n "$target_runtime_proof_file" ]; then printf 'target_runtime_proof_file_status=provided\n'; else printf 'target_runtime_proof_file_status=not-provided\n'; fi + if [ -n "$target_load_proof_file" ]; then printf 'target_load_proof_file_status=provided\n'; else printf 'target_load_proof_file_status=not-provided\n'; fi + printf 'os=%s\n' "$(uname -s 2>/dev/null || printf unknown)" + printf 'kernel_release=%s\n' "$(uname -r 2>/dev/null || printf unknown)" + printf 'machine=%s\n' "$(uname -m 2>/dev/null || printf unknown)" + if command -v curl >/dev/null 2>&1; then printf 'curl_status=present\n'; else printf 'curl_status=missing\n'; fi } > "$out_dir/environment.txt" if [ "$proof_blocked" -ne 0 ]; then