Harden PGP release evidence privacy
ober
e7b2f9bafddfc30200230966affdeeadb6923ff1
--- a/.jerboa/security.json +++ b/.jerboa/security.json @@ -19,6 +19,12 @@ "crypto": { "zeroizeSecrets": true, "strictKeyFilePermissions": true }, "targetEvidence": { "keyProof": "fail-closed", + "proofMaterialPolicy": { + "maxBytes": 65536, + "rejectBinary": true, + "rejectPrivatePaths": true, + "rejectSecretPatterns": true + }, "requiredMarkers": [ "age_encrypt_decrypt_status=target-evidence-recorded", "signature_roundtrip_status=target-evidence-recorded", @@ -30,6 +36,22 @@ "production_crypto_review_status=current-run-recorded", "sensitive_artifact_policy=no-secret-identities-passphrases-plaintext-or-decrypted-payloads-preserved" ] + }, + "releaseEvidence": { + "sanitizeEvidence": "scripts/sanitize-evidence.sh", + "evidencePrivacyStatus": "sanitized", + "privatePathScanStatus": "pass", + "binaryMode": "compile-whole-program standalone default", + "forbiddenEvidenceMaterial": [ + "host-private absolute paths", + "SSH clone URLs", + "authorization headers", + "API tokens", + "passwords or passphrases", + "private keys", + "age secret keys", + "plaintext or decrypted payloads" + ] } }, "suppressions": [] --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ endif XC_LIBDIRS := $(CURDIR):$(JERBOA_SOURCE_HOME)/lib:$(JERBOA_SOURCE_HOME)/.chez-cross-ta6le/lib:$(JERBOA_SOURCE_HOME)/.chez-cross-ta6fb/lib -.PHONY: help run test test-interop build-native binary binary-version-smoke install install-script clean linux-amd64 freebsd-amd64 linux freebsd jpg-linux-amd64 jpg-freebsd-amd64 ensure-jerboa-tools audit verify security sbom reproducibility-report target-evidence release-evidence +.PHONY: help run test test-interop build-native binary binary-version-smoke install install-script clean linux-amd64 freebsd-amd64 linux freebsd jpg-linux-amd64 jpg-freebsd-amd64 ensure-jerboa-tools audit verify security sbom reproducibility-report target-evidence sanitize-evidence release-evidence .DEFAULT_GOAL := help help: @@ -36,6 +36,7 @@ help: @echo " make test Run smoke tests" @echo " make test-interop Report disabled OpenPGP interop status" @echo " make release-evidence Write test/audit/SBOM/reproducibility evidence" + @echo " make sanitize-evidence Redact/check local release evidence" @echo "" @echo "Distribution:" @echo " make binary Build native binary (requires Chez+Jerboa)" @@ -91,6 +92,12 @@ security: exit 1; \ fi; \ echo "secret_scan_status=pass" + @test -x scripts/sanitize-evidence.sh || { echo "scripts/sanitize-evidence.sh is required and must be executable."; exit 1; } + @grep -q 'private or sensitive evidence material remains' scripts/sanitize-evidence.sh || { echo "sanitize-evidence must fail when private/sensitive material remains."; exit 1; } + @grep -q 'scripts/sanitize-evidence.sh' scripts/release-evidence.sh || { echo "release-evidence must invoke scripts/sanitize-evidence.sh."; exit 1; } + @grep -q 'evidence_privacy_status=sanitized' scripts/release-evidence.sh || { echo "release-evidence must report sanitized evidence privacy status."; exit 1; } + @grep -q 'proofMaterialPolicy' .jerboa/security.json || { echo ".jerboa/security.json must document proof material policy."; exit 1; } + @grep -q 'target-key-proof-contains-sensitive-material' scripts/target-evidence.sh || { echo "target proof files must reject sensitive material."; exit 1; } BINARY_OUTPUT ?= jpg-bin @@ -135,6 +142,9 @@ target-evidence: TARGET_EVIDENCE_DIR="$(TARGET_EVIDENCE_DIR)" \ sh scripts/target-evidence.sh +sanitize-evidence: + @scripts/sanitize-evidence.sh "$(RELEASE_EVIDENCE_DIR)" + release-evidence: @REPO_ROOT="$(CURDIR)" \ DIST_DIR="$(DIST_DIR)" \ --- a/docs/release-evidence.md +++ b/docs/release-evidence.md @@ -19,6 +19,16 @@ checks fail. OpenPGP interop is intentionally disabled so the production native dependency graph can pass strict RustSec audit. See `docs/dependency-provenance.md` for the current dependency decision record. +Evidence is sanitized before the top-level result is written. Host-private +paths, SSH clone URLs, authorization headers, tokens, passphrases, private keys, +and plaintext/decrypted payload labels are redacted or rejected, and +`result.txt` records: + +```text +evidence_privacy_status=sanitized +private_path_scan_status=pass +``` + ## Target Key Proof Default local evidence records live key/passphrase release proof as @@ -46,5 +56,9 @@ sensitive_artifact_policy=no-secret-identities-passphrases-plaintext-or-decrypte ``` Missing, empty, or marker-incomplete required proof files fail closed with -`status=blocked-target-proof`. Accepted proof files are copied into release -evidence with a SHA-256 sidecar and still require review before production. +`status=blocked-target-proof`. Proof files must also be short text evidence, +not copied secrets: binary files, oversized files, host-private paths, SSH +URLs, authorization headers, API tokens, private keys, age secret keys, +passphrases, plaintext, or decrypted payload material fail closed before the +proof is copied. Accepted proof files are copied into release evidence with a +SHA-256 sidecar and still require review before production. --- a/scripts/release-evidence.sh +++ b/scripts/release-evidence.sh @@ -5,6 +5,7 @@ repo_root=${REPO_ROOT:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)} dist_dir=${DIST_DIR:-"$repo_root/dist"} evidence_dir=${RELEASE_EVIDENCE_DIR:-"$dist_dir/release-evidence"} make_cmd=${MAKE:-make} +sanitizer="$repo_root/scripts/sanitize-evidence.sh" mkdir -p "$dist_dir" rm -rf "$evidence_dir" "$dist_dir/sbom" "$dist_dir/reproducibility" "$dist_dir/target-evidence" mkdir -p "$evidence_dir" @@ -45,6 +46,16 @@ sha256_file() { git status --short > "$evidence_dir/git-status.txt" git rev-parse HEAD > "$evidence_dir/git-head.txt" 2>/dev/null || true +{ + echo "repo=jerboa-pgp" + echo "generated_at_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + echo "platform=$(uname -srm)" + if "$make_cmd" --version >/dev/null 2>&1; then + echo "make_status=present" + else + echo "make_status=unavailable" + fi +} > "$evidence_dir/build-environment.txt" run_step security "$make_cmd" --no-print-directory security run_step test "$make_cmd" --no-print-directory test @@ -67,9 +78,31 @@ find .jerboa/security.json Makefile README.md SECURITY.md LICENSE PLAN.md docs p sha256_file "$file" done > "$evidence_dir/source-hashes.sha256" +if [ ! -f "$evidence_dir/reproducibility/report.txt" ] || ! grep -qx 'status=match' "$evidence_dir/reproducibility/report.txt"; then + fail=1 +fi + +privacy_fail=0 +if [ -x "$sanitizer" ]; then + if ! "$sanitizer" "$evidence_dir"; then + fail=1 + privacy_fail=1 + fi +else + fail=1 + privacy_fail=1 +fi + if [ "$fail" -ne 0 ]; then { echo "release_evidence_status=blocked" + if [ "$privacy_fail" -eq 0 ]; then + echo "evidence_privacy_status=sanitized" + echo "private_path_scan_status=pass" + else + echo "evidence_privacy_status=blocked" + echo "private_path_scan_status=fail" + fi echo "blocked_reason=one-or-more-production-gates-failed" } > "$evidence_dir/result.txt" echo "Release evidence written to $evidence_dir" @@ -77,5 +110,9 @@ if [ "$fail" -ne 0 ]; then exit 1 fi -echo "release_evidence_status=pass" > "$evidence_dir/result.txt" +{ + echo "release_evidence_status=pass" + echo "evidence_privacy_status=sanitized" + echo "private_path_scan_status=pass" +} > "$evidence_dir/result.txt" echo "Release evidence written to $evidence_dir" --- a/scripts/reproducibility-report.sh +++ b/scripts/reproducibility-report.sh @@ -27,7 +27,6 @@ hash_source_manifest() { find .jerboa/security.json Makefile README.md SECURITY.md LICENSE PLAN.md docs pgp pgp-native support test .github scripts \ -type f \ ! -path '*/dist/*' \ - ! -path '*/.jerboa/*' \ ! -path '*/target/*' \ ! -name '*.so' \ ! -name '*.dylib' \ @@ -114,15 +113,24 @@ stabilize_once() { cd "$repo_root" { - echo "repo=$repo_root" + echo "repo=jerboa-pgp" echo "generated_at_utc=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - echo "jerbuild=$jerbuild" - "$jerbuild" --version 2>/dev/null || true + if "$jerbuild" --version >/dev/null 2>&1; then + echo "jerbuild_status=present" + printf 'jerbuild_version=' + "$jerbuild" --version 2>/dev/null || true + else + echo "jerbuild_status=unavailable" + fi rustc --version 2>/dev/null || true cargo --version 2>/dev/null || true echo "bin=$bin_name" - echo "native_staticlib=$native_staticlib" - echo "native_lib=$native_lib" + echo "native_staticlib_path_status=configured" + if [ -n "$native_lib" ]; then + echo "native_dylib_path_status=configured" + else + echo "native_dylib_path_status=unconfigured" + fi } > "$out_dir/build-env.txt" clean_scheme_artifacts new file mode 100755 --- /dev/null +++ b/scripts/sanitize-evidence.sh @@ -0,0 +1,61 @@ +#!/bin/sh +set -eu + +if [ "$#" -eq 0 ]; then + set -- dist/release-evidence +fi + +sanitize_file() { + file=$1 + + if ! LC_ALL=C grep -Iq . "$file" 2>/dev/null; then + return 0 + fi + + perl -0pi -e ' + s#/Users/[^[:space:]\x22\x27()]+#<local-path>#g; + s#/private/var/folders/[^[:space:]\x22\x27()]+#<local-temp-path>#g; + s#/var/folders/[^[:space:]\x22\x27()]+#<local-temp-path>#g; + if (defined $ENV{HOME} && length $ENV{HOME}) { + my $home = quotemeta($ENV{HOME}); + s#$home#<home>#g; + } + s#~/mine#<local-path>#g; + s#git\@#git-at-#g; + s#ssh://[^[:space:]\x22\x27()]+#ssh://<redacted>#g; + s#Authorization:[^\x0a]*#Authorization: <redacted>#gi; + s#((api[_-]?key|token|password|secret|passphrase|private[_-]?key|identity|plaintext|decrypted[_-]?payload)[[:space:]]*[:=][[:space:]]*)[^[:space:]\x0a]+#${1}<redacted>#gi; + ' "$file" +} + +scan_for_private_material() { + evidence_dir=$1 + + if command -v rg >/dev/null 2>&1; then + if rg -n -S --hidden \ + -e '/Users/' \ + -e '/private/var/folders/' \ + -e '/var/folders/' \ + -e 'git@' \ + -e 'ssh://[^[:space:]]+' \ + -e '-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----' \ + -e 'AGE-SECRET-KEY-1[A-Za-z0-9_-]+' \ + -e 'Authorization:[[:space:]]*[^<[:space:]]' \ + -e '(api[_-]?key|token|password|secret|passphrase|private[_-]?key|identity|plaintext|decrypted[_-]?payload)[[:space:]]*[:=][[:space:]]*[^<[:space:]]' \ + "$evidence_dir"; then + printf '[pgp-sanitize] private or sensitive evidence material remains in %s\n' "$evidence_dir" >&2 + return 1 + fi + 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 + sanitize_file "$file" + done + scan_for_private_material "$evidence_dir" +done --- a/scripts/sbom.sh +++ b/scripts/sbom.sh @@ -39,16 +39,25 @@ hash_manifest() { } cd "$repo_root" -jerboa_home=$("$jerbuild" --jerboa-home 2>/dev/null || true) { - echo "repo=$repo_root" + echo "repo=jerboa-pgp" 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" - "$jerbuild" --version 2>/dev/null || true - echo "jerboa_home=$jerboa_home" + echo "platform=$(uname -srm)" + if "$jerbuild" --version >/dev/null 2>&1; then + echo "jerbuild_status=present" + printf 'jerbuild_version=' + "$jerbuild" --version 2>/dev/null || true + if "$jerbuild" --jerboa-home >/dev/null 2>&1; then + echo "jerboa_home_status=present" + else + echo "jerboa_home_status=unavailable" + fi + else + echo "jerbuild_status=unavailable" + echo "jerboa_home_status=unavailable" + fi rustc --version 2>/dev/null || true cargo --version 2>/dev/null || true cargo audit --version 2>/dev/null || true @@ -91,8 +100,17 @@ hash_manifest "$repo_root/pgp-native" "$out_dir/native-source.sha256" hash_manifest "$repo_root/test" "$out_dir/test-source.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" hash_manifest "$repo_root/.github" "$out_dir/github-workflows.sha256" +{ + for file in Makefile .jerboa/security.json SECURITY.md README.md PLAN.md docs/release-evidence.md scripts/release-evidence.sh scripts/reproducibility-report.sh scripts/sanitize-evidence.sh scripts/sbom.sh scripts/target-evidence.sh; do + if [ -f "$file" ]; then + sha256_file "$file" + fi + done +} > "$out_dir/release-inputs.sha256" + find "$repo_root" -maxdepth 1 -type f \ \( -name '*.ss' -o -name '*.chez' -o -name 'Makefile' -o -name 'README.md' -o -name 'SECURITY.md' -o -name 'LICENSE' -o -name '*.md' \) \ -print | LC_ALL=C sort | while IFS= read -r file; do --- a/scripts/target-evidence.sh +++ b/scripts/target-evidence.sh @@ -6,6 +6,7 @@ dist_dir=${DIST_DIR:-"$repo_root/dist"} out_dir=${TARGET_EVIDENCE_DIR:-"$dist_dir/target-evidence"} proof_file=${JPGP_TARGET_KEY_PROOF_FILE:-} require_proof=${JPGP_REQUIRE_TARGET_KEY_PROOF:-0} +max_proof_bytes=${JPGP_MAX_PROOF_BYTES:-65536} rm -rf "$out_dir" mkdir -p "$out_dir" @@ -54,6 +55,40 @@ require_marker() { fi } +reject_sensitive_proof_material() { + if ! LC_ALL=C grep -Iq . "$proof_file" 2>/dev/null; then + write_blocked_status "target-key-proof-must-be-text" + exit 1 + fi + + proof_bytes=$(wc -c < "$proof_file" | tr -d ' ') + case "$proof_bytes" in + ''|*[!0-9]*) + write_blocked_status "target-key-proof-size-unavailable" + exit 1 + ;; + esac + if [ "$proof_bytes" -gt "$max_proof_bytes" ]; then + write_blocked_status "target-key-proof-too-large" + exit 1 + fi + + if LC_ALL=C grep -I -E -q \ + -e '/Users/' \ + -e '/private/var/folders/' \ + -e '/var/folders/' \ + -e 'git@' \ + -e 'ssh://[^[:space:]]+' \ + -e '-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----' \ + -e 'AGE-SECRET-KEY-1[A-Za-z0-9_-]+' \ + -e 'Authorization:[[:space:]]*[^<[:space:]]' \ + -e '(api[_-]?key|token|password|secret|passphrase|private[_-]?key|identity|plaintext|decrypted[_-]?payload)[[:space:]]*[:=][[:space:]]*[^<[:space:]]' \ + "$proof_file"; then + write_blocked_status "target-key-proof-contains-sensitive-material" + exit 1 + fi +} + if [ -z "$proof_file" ]; then if [ "$require_proof" = "1" ]; then write_blocked_status "target-key-proof-required-but-not-provided" @@ -68,6 +103,8 @@ if [ ! -s "$proof_file" ]; then exit 1 fi +reject_sensitive_proof_material + : > "$out_dir/missing-markers.txt" require_marker "age_encrypt_decrypt_status=target-evidence-recorded" require_marker "signature_roundtrip_status=target-evidence-recorded"