From 70ef90745cf18ccb284abdfcd2c8ca5e859c9c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Avic=20Simmons?= Date: Thu, 16 Jul 2026 06:07:47 -0400 Subject: [PATCH] fix(wipe): harden verify_wipe and host_info audit safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes several correctness bugs in the NIST 800-88 wipe library that could cause the post-wipe verification and audit log to silently misreport. verify_wipe (FR-076/077/092/093/098): - Method-aware pattern check. Overwrite methods (nwipe random, used for hdd/usb-flash/fallback) leave RANDOM data; the old all-zeros/0xFF check ALWAYS failed on them (false negative). Now overwrite types verify that a sample is not an unwritten all-0x00 block, while erase types (nvme-ssd/sata-ssd and SED crypto variants) still require zeros/0xFF. - Full-width sample offset. RANDOM is only 15 bits, so RANDOM*RANDOM (max ~1.07e9 blocks ≈ 550GB) never sampled the tail of larger drives; a 2TB drive sampled only ~27% of its capacity. Combine three draws for 45 bits of range, covering any real drive. - Detect read failures. A failed dd read is no longer silently treated as a passing sample; with pipefail the I/O error fails verification. - Add od -v. Without it, od collapses repeated 16-byte lines to a "*" marker, so even a correctly all-zeroed drive never matched the pattern. - verify_wipe now takes the drive type; wipe-now.sh and wipe-wizard.sh pass it through. host_info (FR-078/094): - Build the host JSON with python3 json.dumps instead of raw printf. A DMI value containing a double-quote produced invalid JSON that crashed init_audit's json.loads() and aborted audit initialization. Values are passed as argv so they cannot inject code. Adds tests/test_verify_wipe.sh covering host_info escaping, the method-aware pattern logic, read-failure handling, and full-range offset coverage on a simulated 2TB drive. --- autorun/wipe-lib.sh | 76 +++++++++++++--- autorun/wipe-now.sh | 2 +- autorun/wipe-wizard.sh | 2 +- tests/test_verify_wipe.sh | 177 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 15 deletions(-) create mode 100755 tests/test_verify_wipe.sh diff --git a/autorun/wipe-lib.sh b/autorun/wipe-lib.sh index 7f1c5e8..a9dda20 100755 --- a/autorun/wipe-lib.sh +++ b/autorun/wipe-lib.sh @@ -171,23 +171,62 @@ wipe_drive() { verify_wipe() { local dev="$1" - local samples=1024 + local dtype="${2:-}" + local samples="${VERIFY_SAMPLES:-1024}" local block_size=512 - local dev_size + local dev_size nblocks dev_size=$(blockdev --getsize64 "/dev/$dev" 2>/dev/null || echo 0) [[ "$dev_size" -eq 0 ]] && return 1 + nblocks=$((dev_size / block_size)) + [[ "$nblocks" -le 0 ]] && return 1 - local all_zero=true - for _ in $(seq 1 "$samples"); do - local offset=$((RANDOM * RANDOM % (dev_size / block_size))) - local data - data=$(dd if="/dev/$dev" bs=$block_size skip="$offset" count=1 2>/dev/null | od -A n -t x1 | tr -d ' \n') - if [[ -n "$data" ]] && ! echo "$data" | grep -qP '^(00|ff)+$'; then - all_zero=false - break + # Expected post-wipe read pattern depends on the sanitization method: + # - Overwrite methods (nwipe random — used for hdd/usb-flash and the + # fallback) leave high-entropy RANDOM data. Expecting zeros there is + # wrong and ALWAYS fails, so we only assert the sample is not an + # unwritten all-0x00 block (the signature of a region the wipe missed). + # - Erase methods (nvme sanitize block-erase, ATA secure-erase, and the + # SED crypto-erase variants) canonically read back as all-0x00 (some + # controllers use 0xFF), so we require that. + # nwipe's own --verify=last is the authoritative pattern check; this is a + # coarse, independent read-back sample across the whole device. + # NOTE: SED crypto-erase read-back is controller-dependent; it is treated + # as an erase-family (zero) check here to match prior behavior. If a SED + # controller leaves ciphertext, revisit this mapping in a follow-up. + local expect + case "$dtype" in + nvme-ssd | sata-ssd | nvme-ssd-sed | sata-ssd-sed) expect="zero" ;; + *) expect="random" ;; + esac + + local i offset data + for ((i = 0; i < samples; i++)); do + # Full-width offset. RANDOM is only 15 bits, so the old RANDOM*RANDOM + # (max ~1.07e9 blocks ≈ 550GB) never sampled the tail of larger drives. + # Combine three draws for 45 bits of range, which covers any real drive. + offset=$(((RANDOM << 30 | RANDOM << 15 | RANDOM) % nblocks)) + # A failed read must NOT be silently treated as a passing sample: with + # pipefail set, a dd I/O error propagates through the pipeline and we + # fail verification rather than skipping the block. + # od -v is REQUIRED: without it od collapses repeated 16-byte lines to a + # "*" marker, so an all-0x00 (or all-0xFF) block never yields a clean run + # of hex pairs and the pattern check silently fails on wiped drives. + if ! data=$(dd if="/dev/$dev" bs="$block_size" skip="$offset" count=1 2>/dev/null | od -A n -v -t x1 | tr -d ' \n'); then + return 1 + fi + [[ -z "$data" ]] && return 1 + if [[ "$expect" == "zero" ]]; then + if ! printf '%s\n' "$data" | grep -qP '^(00)+$|^(ff)+$'; then + return 1 + fi + else + # All-0x00 => unwritten/zeroed region the overwrite did not cover. + if printf '%s\n' "$data" | grep -qP '^(00)+$'; then + return 1 + fi fi done - $all_zero + return 0 } host_info() { @@ -196,8 +235,19 @@ host_info() { manufacturer=$(dmidecode -s system-manufacturer 2>/dev/null || echo "unknown") model=$(dmidecode -s system-product-name 2>/dev/null || echo "unknown") service_tag=$(dmidecode -s system-serial-number 2>/dev/null || echo "unknown") - printf '{"hostname":"%s","manufacturer":"%s","model":"%s","service_tag":"%s"}' \ - "$hostname" "$manufacturer" "$model" "$service_tag" + # Build JSON with python3 so quotes/backslashes/newlines in DMI strings are + # properly escaped. Raw printf produced invalid JSON when a DMI value (e.g. + # manufacturer) contained a double-quote, which crashed init_audit's + # json.loads() and aborted audit initialization. Values are passed as argv + # (not interpolated into the program text) so they cannot inject code. + python3 -c ' +import json, sys +print(json.dumps({ + "hostname": sys.argv[1], + "manufacturer": sys.argv[2], + "model": sys.argv[3], + "service_tag": sys.argv[4], +}))' "$hostname" "$manufacturer" "$model" "$service_tag" } init_audit() { diff --git a/autorun/wipe-now.sh b/autorun/wipe-now.sh index 0c207ba..410a32d 100755 --- a/autorun/wipe-now.sh +++ b/autorun/wipe-now.sh @@ -79,7 +79,7 @@ for entry in "${drives[@]}"; do verify_ok="false" if [[ "$result" == "success" ]]; then - if verify_wipe "$name"; then + if verify_wipe "$name" "$dtype"; then verify_ok="true" fi fi diff --git a/autorun/wipe-wizard.sh b/autorun/wipe-wizard.sh index 44c2a76..edc105b 100755 --- a/autorun/wipe-wizard.sh +++ b/autorun/wipe-wizard.sh @@ -126,7 +126,7 @@ for entry in "${confirmed[@]}"; do verify_ok="false" if [[ "$result" == "success" ]]; then - if verify_wipe "$name"; then + if verify_wipe "$name" "$dtype"; then verify_ok="true" log "Verification passed for /dev/$name" else diff --git a/tests/test_verify_wipe.sh b/tests/test_verify_wipe.sh new file mode 100755 index 0000000..3dfbaa3 --- /dev/null +++ b/tests/test_verify_wipe.sh @@ -0,0 +1,177 @@ +#!/bin/bash +# Tests for host_info() JSON escaping and the method-aware verify_wipe() in +# wipe-lib.sh. External commands (dmidecode, blockdev, dd) are stubbed via a +# PATH-prepended mock dir; od/tr/grep run for real. + +set -euo pipefail + +MOCK_DIR="$(mktemp -d)" +trap 'rm -rf "$MOCK_DIR"' EXIT + +# --- Mock dmidecode: returns whatever MOCK_DMI_VALUE holds (verbatim) -------- +cat << 'MOCK_EOF' > "$MOCK_DIR/dmidecode" +#!/bin/bash +printf '%s\n' "$MOCK_DMI_VALUE" +MOCK_EOF +chmod +x "$MOCK_DIR/dmidecode" + +# --- Mock blockdev: MOCK_DEV_SIZE bytes ------------------------------------- +cat << 'MOCK_EOF' > "$MOCK_DIR/blockdev" +#!/bin/bash +# called as: blockdev --getsize64 /dev/X +printf '%s\n' "${MOCK_DEV_SIZE:-0}" +MOCK_EOF +chmod +x "$MOCK_DIR/blockdev" + +# --- Mock dd: emits one block per MOCK_DD_MODE, records skip offsets --------- +# MOCK_DD_MODE: zero | ff | random | allzero | fail +cat << 'MOCK_EOF' > "$MOCK_DIR/dd" +#!/bin/bash +skip=0 +for a in "$@"; do + case "$a" in + skip=*) skip="${a#skip=}" ;; + esac +done +[[ -n "${MOCK_SKIP_LOG:-}" ]] && printf '%s\n' "$skip" >> "$MOCK_SKIP_LOG" +case "${MOCK_DD_MODE:-random}" in + fail) exit 1 ;; + zero|allzero) head -c 512 /dev/zero ;; + ff) head -c 512 /dev/zero | tr '\000' '\377' ;; + random) head -c 512 /dev/urandom ;; +esac +MOCK_EOF +chmod +x "$MOCK_DIR/dd" + +export PATH="$MOCK_DIR:$PATH" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck disable=SC1091 +source "$REPO_ROOT/autorun/wipe-lib.sh" + +fails=0 +pass() { echo "PASS: $1"; } +failt() { + echo "FAIL: $1" + fails=$((fails + 1)) +} + +# Keep verify_wipe loops cheap. +export VERIFY_SAMPLES=64 + +echo "== host_info() JSON escaping ==" + +# A DMI value with an embedded double-quote used to produce invalid JSON. +export MOCK_DMI_VALUE='Dell Inc. "Special" Edition' +hi="$(host_info)" +if printf '%s' "$hi" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then + pass "host_info emits valid JSON when DMI value contains double-quotes" +else + failt "host_info produced invalid JSON: $hi" +fi + +# And the escaped value round-trips intact. +got="$(printf '%s' "$hi" | python3 -c 'import json,sys; print(json.load(sys.stdin)["manufacturer"])')" +if [[ "$got" == 'Dell Inc. "Special" Edition' ]]; then + pass "host_info preserves the quoted manufacturer value" +else + failt "host_info mangled the value: got [$got]" +fi + +# Backslashes must not break JSON either. +export MOCK_DMI_VALUE='Back\slash \ Co' +if host_info | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then + pass "host_info emits valid JSON when DMI value contains backslashes" +else + failt "host_info produced invalid JSON for a backslash value" +fi + +echo "== verify_wipe() method-aware pattern ==" + +export MOCK_DEV_SIZE=$((512 * 1000000)) # ~512MB, plenty of blocks + +# Overwrite methods: random data must PASS, all-zero must FAIL. +export MOCK_DD_MODE=random +if verify_wipe "sdX" "hdd"; then + pass "hdd (random overwrite): random data verifies OK (no false negative)" +else + failt "hdd random data was wrongly rejected" +fi + +export MOCK_DD_MODE=allzero +if ! verify_wipe "sdX" "hdd"; then + pass "hdd (random overwrite): all-zero (unwritten) region fails verification" +else + failt "hdd all-zero region wrongly passed verification" +fi + +export MOCK_DD_MODE=random +if ! verify_wipe "sdX" "usb-flash"; then + failt "usb-flash random data was wrongly rejected" +else + pass "usb-flash (random overwrite): random data verifies OK" +fi + +# Erase methods: zeros/0xFF must PASS, random must FAIL. +export MOCK_DD_MODE=zero +if verify_wipe "sdX" "nvme-ssd"; then + pass "nvme-ssd (block erase): all-zero verifies OK" +else + failt "nvme-ssd all-zero was wrongly rejected" +fi + +export MOCK_DD_MODE=ff +if verify_wipe "sdX" "sata-ssd"; then + pass "sata-ssd (secure erase): all-0xFF verifies OK" +else + failt "sata-ssd all-0xFF was wrongly rejected" +fi + +export MOCK_DD_MODE=random +if ! verify_wipe "sdX" "nvme-ssd"; then + pass "nvme-ssd (block erase): leftover random data fails verification" +else + failt "nvme-ssd random data wrongly passed verification" +fi + +echo "== verify_wipe() read-failure detection ==" + +export MOCK_DD_MODE=fail +if ! verify_wipe "sdX" "hdd"; then + pass "dd read failure fails verification (not silently skipped)" +else + failt "dd read failure was silently treated as a pass" +fi + +echo "== verify_wipe() zero-size guard ==" +export MOCK_DEV_SIZE=0 +export MOCK_DD_MODE=random +if ! verify_wipe "sdX" "hdd"; then + pass "zero-size device fails verification" +else + failt "zero-size device wrongly passed" +fi + +echo "== verify_wipe() full-width offset coverage (>500GB) ==" +# 2TB device: the old RANDOM*RANDOM ceiling (~1.07e9 blocks) could not reach +# past ~550GB. Assert sampled offsets exceed that ceiling. +export MOCK_DEV_SIZE=$((2 * 1000 * 1000 * 1000 * 1000)) # 2 TB +export MOCK_DD_MODE=random +export MOCK_SKIP_LOG="$MOCK_DIR/skips.log" +: > "$MOCK_SKIP_LOG" +VERIFY_SAMPLES=500 verify_wipe "sdX" "hdd" || true +max_skip=$(sort -n "$MOCK_SKIP_LOG" | tail -1) +old_ceiling=1073676289 # 32767 * 32767 +if [[ "${max_skip:-0}" -gt "$old_ceiling" ]]; then + pass "offsets reach past the old RANDOM*RANDOM ceiling (max=$max_skip > $old_ceiling)" +else + failt "offsets never exceeded old ceiling (max=${max_skip:-0}); tail of large drives unreachable" +fi +unset MOCK_SKIP_LOG + +if [[ $fails -eq 0 ]]; then + echo "All verify_wipe/host_info tests passed." +else + echo "$fails test(s) failed." + /bin/false +fi