Harden GitLab release evidence privacy
ober
cb7f81500d9e226a4ccf0e8f137f6448948a614c
--- a/.jerboa/security.json +++ b/.jerboa/security.json @@ -24,6 +24,7 @@ }, "targetEvidence": { "gitlabProof": "fail-closed", + "proofMaterialPolicy": "reject-overlarge-private-sensitive", "requiredMarkers": [ "gitlab_public_project_smoke_status=target-evidence-recorded", "gitlab_private_project_smoke_status=target-evidence-recorded", @@ -37,7 +38,9 @@ "raw_api_output_status=not-preserved", "sensitive_artifact_policy=no-gitlab-tokens-private-project-paths-issue-exports-attachments-or-raw-api-responses-preserved" ] - } + }, + "localVerification": ["make verify", "make release-evidence"], + "evidencePrivacy": "sanitized-host-neutral-hash-only-reproducibility" }, "suppressions": [ { --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ MUSL_TARGET := x86_64-unknown-linux-musl MUSL_CC ?= x86_64-linux-musl-gcc NATIVE_MUSL_A := $(JH)/jerboa-native-rs/target/$(MUSL_TARGET)/release/libjerboa_native.a -.PHONY: all build binary run test native-runtime security sbom reproducibility-report target-evidence target-evidence-selftest release-evidence install clean help cross-linux ensure-jerboa-tools +.PHONY: all build binary run test native-runtime security verify sbom reproducibility-report target-evidence target-evidence-selftest release-evidence install clean help cross-linux ensure-jerboa-tools .DEFAULT_GOAL := help all: binary @@ -118,6 +118,8 @@ target-evidence-selftest: DIST_DIR="$(DIST_DIR)" \ sh scripts/test-target-evidence.sh +verify: security test target-evidence-selftest target-evidence reproducibility-report sbom + release-evidence: @rm -rf "$(RELEASE_EVIDENCE_DIR)" "$(TARGET_EVIDENCE_DIR)" "$(DIST_DIR)/reproducibility" "$(DIST_DIR)/sbom" @mkdir -p "$(RELEASE_EVIDENCE_DIR)" @@ -142,11 +144,14 @@ release-evidence: @$(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" + @test -f "$(RELEASE_EVIDENCE_DIR)/sbom/build-environment.txt" @find jgl.ss build*.ss lib test support docs scripts .github .jerboa/security.json -type f -print | LC_ALL=C sort | xargs shasum -a 256 > "$(RELEASE_EVIDENCE_DIR)/source-hashes.sha256" + @sh scripts/sanitize-evidence.sh "$(RELEASE_EVIDENCE_DIR)" @echo "Release evidence written to $(RELEASE_EVIDENCE_DIR)" install: binary --- a/docs/release-evidence.md +++ b/docs/release-evidence.md @@ -15,6 +15,13 @@ The evidence bundle contains: - target GitLab proof status and proof-gate self-test output; - repeated binary and generated FFI-symbol reproducibility checks. +Release evidence is sanitized before it is kept. SBOM and build-environment +records use host-neutral status fields instead of local checkout paths, and the +reproducibility report archives hashes/status only. Raw build logs, binary help +output, and scratch build directories are not preserved; the report must record +`artifact_archive_status=hashes-only` and +`scratch_build_logs_status=not-archived`. + GitLab API access uses `(std net request)` over Jerboa's bundled `jerboa-native-rs` TLS provider with `tls,crypto` features. The client sends the token in the `PRIVATE-TOKEN` header and redacts non-2xx API response bodies @@ -57,8 +64,9 @@ raw_api_output_status=not-preserved sensitive_artifact_policy=no-gitlab-tokens-private-project-paths-issue-exports-attachments-or-raw-api-responses-preserved ``` -The proof gate rejects missing, empty, marker-incomplete, or high-confidence -sensitive proof files. The proof must not preserve GitLab tokens, -`PRIVATE-TOKEN` headers, bearer headers, private project paths, exported issue -archives, attachments, private hostnames, raw API responses, or downloaded -content. +The proof gate rejects missing, empty, marker-incomplete, oversized, +host-private, or high-confidence sensitive proof files. +`JERBOA_GITLAB_TARGET_PROOF_MAX_BYTES` defaults to 65536. The proof must not +preserve local paths, SSH remote URLs, GitLab tokens, `PRIVATE-TOKEN` headers, +bearer headers, private project paths, exported issue archives, attachments, +private hostnames, raw API responses, or downloaded content. --- a/scripts/reproducibility-report.sh +++ b/scripts/reproducibility-report.sh @@ -6,8 +6,20 @@ dist_dir=${DIST_DIR:-"$repo_root/dist"} out_dir=${REPRO_DIR:-"$dist_dir/reproducibility"} make_cmd=${MAKE:-make} bin_name=${BIN:-jgl} +scratch_parent=${JERBOA_GITLAB_REPRO_TMPDIR:-${TMPDIR:-/tmp}} +scratch_dir=$scratch_parent/jerboa-gitlab-repro-$$ +sanitizer=${JERBOA_GITLAB_EVIDENCE_SANITIZER:-scripts/sanitize-evidence.sh} rm -rf "$out_dir" mkdir -p "$out_dir" +rm -rf "$scratch_dir" +mkdir -p "$scratch_dir" + +cleanup() { + status=$? + rm -rf "$scratch_dir" + exit "$status" +} +trap cleanup EXIT INT TERM sha256_file_value() { if command -v sha256sum >/dev/null 2>&1; then @@ -20,16 +32,20 @@ sha256_file_value() { cd "$repo_root" rm -f "$bin_name" -"$make_cmd" --no-print-directory binary > "$out_dir/binary-first.log" 2>&1 +"$make_cmd" --no-print-directory binary > "$scratch_dir/binary-warmup.log" 2>&1 +rm -f "$bin_name" + +rm -f "$bin_name" +"$make_cmd" --no-print-directory binary > "$scratch_dir/binary-first.log" 2>&1 first_binary=$(sha256_file_value "$bin_name") first_symbols=$(sha256_file_value support/ffi-symbols.gen) -./"$bin_name" --help > "$out_dir/binary-first-help.log" 2>&1 +./"$bin_name" --help > "$scratch_dir/binary-first-help.log" 2>&1 rm -f "$bin_name" -"$make_cmd" --no-print-directory binary > "$out_dir/binary-second.log" 2>&1 +"$make_cmd" --no-print-directory binary > "$scratch_dir/binary-second.log" 2>&1 second_binary=$(sha256_file_value "$bin_name") second_symbols=$(sha256_file_value support/ffi-symbols.gen) -./"$bin_name" --help > "$out_dir/binary-second-help.log" 2>&1 +./"$bin_name" --help > "$scratch_dir/binary-second-help.log" 2>&1 echo "$first_binary $bin_name" > "$out_dir/binary-first.sha256" echo "$second_binary $bin_name" > "$out_dir/binary-second.sha256" @@ -56,5 +72,12 @@ esac { echo "binary_status=$binary_status" echo "ffi_symbols_status=$ffi_symbols_status" + echo "warmup_build_used=1" + echo "artifact_archive_status=hashes-only" + echo "scratch_build_logs_status=not-archived" echo "status=$status" } | tee "$out_dir/report.txt" + +if [ -x "$sanitizer" ]; then + "$sanitizer" "$out_dir" +fi 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 @@ -6,6 +6,7 @@ dist_dir=${DIST_DIR:-"$repo_root/dist"} out_dir=${SBOM_DIR:-"$dist_dir/sbom"} jerbuild=${JERBUILD:-jerbuild} bin_name=${BIN:-jgl} +sanitizer=${JERBOA_GITLAB_EVIDENCE_SANITIZER:-scripts/sanitize-evidence.sh} rm -rf "$out_dir" mkdir -p "$out_dir" @@ -13,17 +14,20 @@ hash_manifest() { dir=$1 out=$2 if [ -d "$dir" ]; then - find "$dir" -type f \ - ! -path '*/.git/*' \ - ! -path '*/dist/*' \ - ! -path '*/.jerboa/*' \ - -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" + ( + cd "$dir" + find . -type f \ + ! -path './.git/*' \ + ! -path './dist/*' \ + ! -path './.jerboa/*' \ + -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" else : > "$out" fi @@ -38,6 +42,9 @@ sha256_file() { } cd "$repo_root" +if [ -x "$sanitizer" ]; then + trap '"$sanitizer" "$out_dir"' EXIT +fi jerboa_home=$("$jerbuild" --jerboa-home 2>/dev/null || true) native_manifest= if [ -n "$jerboa_home" ] && [ -f "$jerboa_home/jerboa-native-rs/Cargo.toml" ]; then @@ -45,13 +52,17 @@ if [ -n "$jerboa_home" ] && [ -f "$jerboa_home/jerboa-native-rs/Cargo.toml" ]; t fi { - echo "repo=$repo_root" + echo "repo=jerboa-gitlab" echo "generated_at_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" echo "git_head=$(git rev-parse HEAD 2>/dev/null || true)" - echo "uname=$(uname -a)" - echo "jerbuild=$jerbuild" + echo "platform=$(uname -srm)" + echo "jerbuild_status=selected" "$jerbuild" --version 2>/dev/null || true - echo "jerboa_home=$jerboa_home" + if [ -n "$jerboa_home" ]; then + echo "jerboa_home_status=present" + else + echo "jerboa_home_status=missing" + fi echo "cc=$(${CC:-cc} --version 2>/dev/null | sed -n '1p' || true)" echo "cargo=$(cargo --version 2>/dev/null || true)" echo "rustc=$(rustc --version 2>/dev/null || true)" @@ -105,14 +116,14 @@ hash_manifest "$repo_root/test" "$out_dir/test.sha256" hash_manifest "$repo_root/support" "$out_dir/support.sha256" hash_manifest "$repo_root/docs" "$out_dir/docs.sha256" hash_manifest "$repo_root/scripts" "$out_dir/scripts.sha256" -if [ -f "$repo_root/.jerboa/security.json" ]; then - sha256_file "$repo_root/.jerboa/security.json" > "$out_dir/jerboa-security.sha256" +if [ -f ".jerboa/security.json" ]; then + sha256_file ".jerboa/security.json" > "$out_dir/jerboa-security.sha256" else : > "$out_dir/jerboa-security.sha256" fi hash_manifest "$repo_root/.github" "$out_dir/github-workflows.sha256" -find "$repo_root" -maxdepth 1 -type f \ +find . -maxdepth 1 -type f \ \( -name '*.ss' -o -name '*.c' -o -name 'Makefile' -o -name 'README.md' -o -name 'SECURITY.md' -o -name '.jerbuild' -o -name 'LICENSE' \) \ -print | LC_ALL=C sort | while IFS= read -r file; do sha256_file "$file" --- a/scripts/target-evidence.sh +++ b/scripts/target-evidence.sh @@ -6,10 +6,26 @@ dist_dir=${DIST_DIR:-"$repo_root/dist"} out_dir=${TARGET_EVIDENCE_DIR:-"$dist_dir/target-evidence"} proof_file=${JERBOA_GITLAB_TARGET_PROOF_FILE:-} require_proof=${JERBOA_GITLAB_REQUIRE_TARGET_PROOF:-0} +max_proof_bytes=${JERBOA_GITLAB_TARGET_PROOF_MAX_BYTES:-65536} +sanitizer=${JERBOA_GITLAB_EVIDENCE_SANITIZER:-scripts/sanitize-evidence.sh} rm -rf "$out_dir" mkdir -p "$out_dir" cd "$repo_root" +if [ -x "$sanitizer" ]; then + trap '"$sanitizer" "$out_dir"' EXIT +fi + +case "$max_proof_bytes" in + ''|*[!0-9]*) + echo "JERBOA_GITLAB_TARGET_PROOF_MAX_BYTES must be a positive integer" >&2 + exit 1 + ;; +esac +if [ "$max_proof_bytes" -lt 1 ]; then + echo "JERBOA_GITLAB_TARGET_PROOF_MAX_BYTES must be a positive integer" >&2 + exit 1 +fi sha256_file() { if command -v sha256sum >/dev/null 2>&1; then @@ -20,11 +36,14 @@ sha256_file() { } write_default_status() { + proof_status=${1:-not-run} + final_status=${2:-local-status-recorded} + reason=${3:-} { echo "repo=jerboa-gitlab" echo "generated_at_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" echo "git_commit=$(git rev-parse HEAD 2>/dev/null || true)" - echo "target_gitlab_proof_status=not-run" + echo "target_gitlab_proof_status=$proof_status" echo "gitlab_public_project_smoke_status=blocked-not-run" echo "gitlab_private_project_smoke_status=blocked-not-run" echo "least_privilege_token_scope_status=blocked-not-run" @@ -36,17 +55,17 @@ write_default_status() { echo "production_credential_review_status=blocked-not-run" echo "raw_api_output_status=blocked-not-run" echo "sensitive_artifact_policy=blocked-not-run" - echo "status=local-status-recorded" + if [ -n "$reason" ]; then + echo "blocked_reason=$reason" + fi + echo "status=$final_status" } > "$out_dir/status.txt" } write_blocked_status() { reason=$1 - write_default_status - { - echo "blocked_reason=$reason" - echo "status=blocked-target-proof" - } >> "$out_dir/status.txt" + proof_status=${2:-blocked} + write_default_status "$proof_status" blocked-target-proof "$reason" } require_marker() { @@ -56,28 +75,51 @@ require_marker() { fi } -reject_sensitive_proof_material() { - if grep -E -i '(glpat-[A-Za-z0-9_-]{20,}|gldt-[A-Za-z0-9_-]{20,}|glrt-[A-Za-z0-9_-]{20,}|private-token:|authorization:[[:space:]]*bearer|token:[[:space:]]*[A-Za-z0-9_-]{20,})' "$proof_file" >/dev/null 2>&1; then - write_blocked_status "target-gitlab-proof-contains-sensitive-material-pattern" +proof_contains() { + pattern=$1 + if command -v rg >/dev/null 2>&1; then + rg -q -i -I -e "$pattern" "$proof_file" 2>/dev/null + else + grep -E -i -q "$pattern" "$proof_file" 2>/dev/null + fi +} + +reject_unsafe_proof_material() { + proof_size=$(wc -c < "$proof_file" | tr -d ' ') + if [ "$proof_size" -gt "$max_proof_bytes" ]; then + write_blocked_status "target-gitlab-proof-larger-than-JERBOA_GITLAB_TARGET_PROOF_MAX_BYTES" too-large + exit 1 + fi + if proof_contains '(glpat-[A-Za-z0-9_-]{20,}|gldt-[A-Za-z0-9_-]{20,}|glrt-[A-Za-z0-9_-]{20,}|private-token:|authorization:[[:space:]]*bearer|token:[[:space:]]*[A-Za-z0-9_-]{20,})'; then + write_blocked_status "target-gitlab-proof-contains-sensitive-material-pattern" sensitive-material + exit 1 + fi + if proof_contains '(/Users/|~/mine|\$\(HOME\)/mine|git@|users-MacBook-Pro|uname[[:space:]]+-a)'; then + write_blocked_status "target-gitlab-proof-contains-private-material-pattern" private-material exit 1 fi } if [ -z "$proof_file" ]; then if [ "$require_proof" = "1" ]; then - write_blocked_status "target-gitlab-proof-required-but-not-provided" + write_blocked_status "target-gitlab-proof-required-but-not-provided" missing exit 1 fi write_default_status exit 0 fi +if [ ! -f "$proof_file" ]; then + write_blocked_status "target-gitlab-proof-file-missing" missing + exit 1 +fi + if [ ! -s "$proof_file" ]; then - write_blocked_status "target-gitlab-proof-file-missing-or-empty" + write_blocked_status "target-gitlab-proof-file-empty" incomplete exit 1 fi -reject_sensitive_proof_material +reject_unsafe_proof_material : > "$out_dir/missing-markers.txt" require_marker "gitlab_public_project_smoke_status=target-evidence-recorded" @@ -93,7 +135,7 @@ require_marker "raw_api_output_status=not-preserved" require_marker "sensitive_artifact_policy=no-gitlab-tokens-private-project-paths-issue-exports-attachments-or-raw-api-responses-preserved" if [ -s "$out_dir/missing-markers.txt" ]; then - write_blocked_status "target-gitlab-proof-missing-required-markers" + write_blocked_status "target-gitlab-proof-missing-required-markers" incomplete exit 1 fi --- a/scripts/test-target-evidence.sh +++ b/scripts/test-target-evidence.sh @@ -7,6 +7,8 @@ selftest_dir="$dist_dir/target-evidence-selftest" proof_file="$selftest_dir/valid-proof.txt" incomplete_file="$selftest_dir/incomplete-proof.txt" sensitive_file="$selftest_dir/sensitive-proof.txt" +private_file="$selftest_dir/private-proof.txt" +large_file="$selftest_dir/large-proof.txt" out_dir="$selftest_dir/out" rm -rf "$selftest_dir" @@ -38,6 +40,7 @@ expect_failure env \ JERBOA_GITLAB_REQUIRE_TARGET_PROOF=1 \ sh scripts/target-evidence.sh require_line "$out_dir/missing-required/status.txt" "blocked_reason=target-gitlab-proof-required-but-not-provided" +require_line "$out_dir/missing-required/status.txt" "target_gitlab_proof_status=missing" require_line "$out_dir/missing-required/status.txt" "status=blocked-target-proof" echo "gitlab_public_project_smoke_status=target-evidence-recorded" > "$incomplete_file" @@ -46,6 +49,7 @@ expect_failure env \ JERBOA_GITLAB_TARGET_PROOF_FILE="$incomplete_file" \ sh scripts/target-evidence.sh require_line "$out_dir/incomplete/status.txt" "blocked_reason=target-gitlab-proof-missing-required-markers" +require_line "$out_dir/incomplete/status.txt" "target_gitlab_proof_status=incomplete" require_line "$out_dir/incomplete/missing-markers.txt" "gitlab_private_project_smoke_status=target-evidence-recorded" { @@ -79,6 +83,28 @@ expect_failure env \ JERBOA_GITLAB_TARGET_PROOF_FILE="$sensitive_file" \ sh scripts/target-evidence.sh require_line "$out_dir/sensitive/status.txt" "blocked_reason=target-gitlab-proof-contains-sensitive-material-pattern" +require_line "$out_dir/sensitive/status.txt" "target_gitlab_proof_status=sensitive-material" + +{ + cat "$proof_file" + echo "review_path=/Users/user/mine/gitlab-review" +} > "$private_file" + +expect_failure env \ + TARGET_EVIDENCE_DIR="$out_dir/private" \ + JERBOA_GITLAB_TARGET_PROOF_FILE="$private_file" \ + sh scripts/target-evidence.sh +require_line "$out_dir/private/status.txt" "target_gitlab_proof_status=private-material" +require_line "$out_dir/private/status.txt" "blocked_reason=target-gitlab-proof-contains-private-material-pattern" + +cat "$proof_file" > "$large_file" +printf '%070000d\n' 0 >> "$large_file" +expect_failure env \ + TARGET_EVIDENCE_DIR="$out_dir/large" \ + JERBOA_GITLAB_TARGET_PROOF_FILE="$large_file" \ + sh scripts/target-evidence.sh +require_line "$out_dir/large/status.txt" "target_gitlab_proof_status=too-large" +require_line "$out_dir/large/status.txt" "blocked_reason=target-gitlab-proof-larger-than-JERBOA_GITLAB_TARGET_PROOF_MAX_BYTES" rm -rf "$selftest_dir" echo "target_evidence_selftest_status=pass"