From cd2afdb308cc5403466a10e68de2cde6e97afd33 Mon Sep 17 00:00:00 2001 From: Flo Date: Sun, 5 Apr 2026 04:22:52 -0700 Subject: [PATCH] feat: tests, device-not-found retry, remove Qubes CTAP2 warning Add 20 BATS tests and 1 interactive test for v0.5.0 edge-case fixes. FIDO2 keygen now prompts to retry on "device not found" instead of exiting. Remove stale Qubes vhci_hcd warning. Update hardware test matrix in README. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 10 + README.md | 10 +- git-harden.sh | 47 ++-- test/git-harden.bats | 323 +++++++++++++++++++++- test/interactive/test-identity-guard.sh | 106 +++++++ test/interactive/test-signing-generate.sh | 13 +- test/interactive/test-signing-skip.sh | 3 + 7 files changed, 464 insertions(+), 48 deletions(-) create mode 100755 test/interactive/test-identity-guard.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c5914f..6f679d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - New SSH directives are placed inside a `Host *` block instead of appended bare to EOF - `--reset-signing` now cleans the actual configured `user.signingkey` path in addition to well-known key names +### Removed +- Qubes OS CTAP2/vhci_hcd warning (PIN-protected keys work over USB passthrough) + ### Fixed - `readonly VERSION` variable conflict when sourcing `/etc/os-release` (replaced `.` with `sed` parse) +- FIDO2 key generation now offers retry when security key is not plugged in ("device not found") +- Admin recommendations suppressed when signing setup was skipped or failed + +### Tests +- 20 new BATS tests (112 total) covering identity guard, pull.rebase unset, SSH `Host *` placement, SSH config backup, dedicated signing key names, core.hooksPath separation, reset-signing with configured paths +- New interactive test: identity guard flow (missing name/email prompts) +- Updated existing tests for dedicated signing key names and inter-test isolation ## [0.4.0] - 2026-04-04 diff --git a/README.md b/README.md index 22ab0c9..981417c 100644 --- a/README.md +++ b/README.md @@ -165,11 +165,11 @@ These combinations of hardware and OS have been tested: | [Yubico Security Key USB A NFC](https://www.yubico.com/products/security-key-by-yubico/usb-a-nfc/) | 5.0.2 | Debian 13 Trixie | | | [Yubico Security Key USB A NFC](https://www.yubico.com/products/security-key-by-yubico/usb-a-nfc/) | 5.0.2 | Fedora 42 | Yes | | [Yubico YubiKey 5C nano](https://support.yubico.com/s/article/YubiKey-5C-Nano) | 5.4.3 | macOS Tahoe | Yes | -| [Yubico YubiKey 5C nano](https://support.yubico.com/s/article/YubiKey-5C-Nano) | 5.4.3 | Debian 13 Trixie | | +| [Yubico YubiKey 5C nano](https://support.yubico.com/s/article/YubiKey-5C-Nano) | 5.4.3 | Debian 13 Trixie | Yes | | [Yubico YubiKey 5C nano](https://support.yubico.com/s/article/YubiKey-5C-Nano) | 5.4.3 | Fedora 42 | Yes | -| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | macOS Tahoe | Yes* | -| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | Debian 13 Trixie| | -| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | Fedora 42| Yes* | +| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | macOS Tahoe | Yes | +| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | Debian 13 Trixie| Yes | +| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | Fedora 42| Yes | | [SoloKeys Solo 1 Tap USB-A](https://solokeys.com/collections/all/products/solo-tap-usb-a-preorder) | | Ubuntu 24.04 | Yes | | [SoloKeys Solo 1 Tap USB-A](https://solokeys.com/collections/all/products/solo-tap-usb-a-preorder) | | Debian 13 Trixie | Yes | | [SoloKeys Solo 1 Tap USB-A](https://solokeys.com/collections/all/products/solo-tap-usb-a-preorder) | | Fedora 42 | Yes | @@ -177,7 +177,7 @@ These combinations of hardware and OS have been tested: | [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | macOS Tahoe | Yes | | [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | Ubuntu 24.04 | Yes | | [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | Debian 13 Trixie | | -| [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | Fedora 42 | | +| [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | Fedora 42 | Yes | ## Running Tests diff --git a/git-harden.sh b/git-harden.sh index c568def..8b2b93b 100755 --- a/git-harden.sh +++ b/git-harden.sh @@ -1507,32 +1507,6 @@ generate_fido2_key() { fi fi - # Qubes OS: USB passthrough (vhci_hcd) corrupts CTAP2 protocol messages. - # Keys with a FIDO2 PIN require CTAP2, which fails over vhci_hcd. - # Warn the user — PIN-protected keys won't work without a functioning - # qubes-ctap proxy. - if [ "$PLATFORM" = "linux" ] && [ -d /sys/bus/hid/devices ]; then - local has_vhci=false - local dev_dir - for dev_dir in /sys/class/hidraw/hidraw*; do - [ -d "$dev_dir" ] || continue - if grep -q 'vhci_hcd' "${dev_dir}/device/uevent" 2>/dev/null; then - has_vhci=true - break - fi - done - if [ "$has_vhci" = true ]; then - print_warn "Qubes OS detected — FIDO2 key is attached via USB passthrough (vhci_hcd)." - printf ' CTAP2 (PIN-protected keys) may fail over USB passthrough.\n' >&2 - printf ' Keys without a PIN (U2F-only) should work.\n' >&2 - printf ' For PIN-protected keys, generate on the host and copy the key pair,\n' >&2 - printf ' or ensure qubes-ctap-proxy is fully configured.\n' >&2 - if ! prompt_yn "Continue anyway?"; then - return - fi - fi - fi - # On macOS, the system ssh-keygen lacks FIDO2 support. Homebrew's openssh # bundles ssh-sk-helper and builds FIDO2 into its own ssh-keygen binary. # Detect by checking for ssh-sk-helper (NOT by running ssh-keygen, which @@ -1637,6 +1611,22 @@ generate_fido2_key() { break fi + # Device not found — offer to plug in and retry the same attempt + if printf '%s' "$keygen_stderr" | grep -qi 'device not found\|no device'; then + rm -f "$key_path" "${key_path}.pub" + printf '\n Security key not detected.\n' >&2 + printf ' Please insert your security key and press Enter to retry (or q to skip): ' >&2 + local retry_reply + read -r retry_reply "${TEST_HOME}/.ssh/id_ed25519_sk.pub" + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q source_functions detect_existing_keys [ "$SIGNING_KEY_FOUND" = true ] - [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_sk.pub" ] + [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_signing.pub" ] +} + +@test "detect_existing_keys prefers sk signing key over software key" { + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q + # Fake an sk signing key (can't generate real one without hardware) + cp "${TEST_HOME}/.ssh/id_ed25519" "${TEST_HOME}/.ssh/id_ed25519_sk_signing" + # Write a fake pub key with sk type prefix + printf 'sk-ssh-ed25519@openssh.com AAAAFakeKey test\n' > "${TEST_HOME}/.ssh/id_ed25519_sk_signing.pub" + + source_functions + detect_existing_keys + + [ "$SIGNING_KEY_FOUND" = true ] + [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_sk_signing.pub" ] } @test "detect_existing_keys finds key from IdentityFile directive" { @@ -691,15 +702,16 @@ SSHEOF [ "$count" -eq 1 ] } -@test "setup_allowed_signers skips when no email set" { +@test "setup_allowed_signers skips when no email provided" { ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q git config --global --unset user.email source_functions SIGNING_PUB_PATH="${TEST_HOME}/.ssh/id_ed25519.pub" + # In non-interactive context, read from /dev/tty fails — empty email run setup_allowed_signers - assert_output --partial "user.email not set" + assert_output --partial "No email provided" } # =========================================================================== @@ -1153,10 +1165,297 @@ EOF } # =========================================================================== -# v0.2.0: Version bump +# v0.5.0: Identity guard (useConfigOnly) # =========================================================================== -@test "--version reports 0.4.0" { - run bash "$SCRIPT" --version - assert_output --partial "0.4.0" +@test "audit warns when useConfigOnly=true but identity missing" { + git config --global --unset user.name + git config --global --unset user.email + + source_functions + run audit_git_config + assert_output --partial "user.name/user.email not set" +} + +@test "audit does not warn about identity when name and email set" { + source_functions + run audit_git_config + refute_output --partial "user.name/user.email not set" +} + +@test "-y mode applies useConfigOnly when identity exists" { + source_functions + AUTO_YES=true + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + + run apply_git_config + assert_success + + [ "$(git config --global user.useConfigOnly)" = "true" ] +} + +@test "-y mode skips useConfigOnly when user.name missing" { + git config --global --unset user.name + + source_functions + AUTO_YES=true + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + + run apply_git_config + assert_success + assert_output --partial "Skipping user.useConfigOnly" + + local result + result="$(git config --global --get user.useConfigOnly 2>/dev/null || true)" + [ -z "$result" ] +} + +@test "-y mode skips useConfigOnly when user.email missing" { + git config --global --unset user.email + + source_functions + AUTO_YES=true + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + + run apply_git_config + assert_success + assert_output --partial "Skipping user.useConfigOnly" + + local result + result="$(git config --global --get user.useConfigOnly 2>/dev/null || true)" + [ -z "$result" ] +} + +# =========================================================================== +# v0.5.0: pull.rebase unset during apply +# =========================================================================== + +@test "-y mode unsets pull.rebase when set" { + git config --global pull.rebase true + + source_functions + AUTO_YES=true + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + + run apply_git_config + assert_success + assert_output --partial "Unset pull.rebase" + + local result + result="$(git config --global --get pull.rebase 2>/dev/null || true)" + [ -z "$result" ] +} + +@test "-y mode does not unset pull.rebase when not set" { + source_functions + AUTO_YES=true + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + + run apply_git_config + assert_success + refute_output --partial "Unset pull.rebase" +} + +# =========================================================================== +# v0.5.0: SSH directives in Host * block +# =========================================================================== + +@test "apply places new SSH directive in Host * block when blocks exist" { + cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF' +Host github.com + IdentityFile ~/.ssh/github_key +SSHEOF + + source_functions + apply_single_ssh_directive "StrictHostKeyChecking" "accept-new" + + # Should have created a Host * block + grep -q "^Host \*$" "${TEST_HOME}/.ssh/config" + grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config" +} + +@test "apply inserts into existing Host * block" { + cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF' +Host * + HashKnownHosts yes + +Host github.com + IdentityFile ~/.ssh/github_key +SSHEOF + + source_functions + apply_single_ssh_directive "IdentitiesOnly" "yes" + + # Should be inside Host * block (indented), not appended bare + grep -q "IdentitiesOnly yes" "${TEST_HOME}/.ssh/config" + # Only one Host * line + local count + count="$(grep -c '^Host \*$' "${TEST_HOME}/.ssh/config")" + [ "$count" -eq 1 ] +} + +@test "apply appends bare when no Host/Match blocks exist" { + : > "${TEST_HOME}/.ssh/config" + + source_functions + apply_single_ssh_directive "HashKnownHosts" "yes" + + grep -q "HashKnownHosts yes" "${TEST_HOME}/.ssh/config" + # No Host * block should be created for a simple file + ! grep -q "^Host" "${TEST_HOME}/.ssh/config" +} + +# =========================================================================== +# v0.5.0: SSH config backup +# =========================================================================== + +@test "apply_ssh_config creates backup of existing SSH config" { + printf 'StrictHostKeyChecking ask\n' > "${TEST_HOME}/.ssh/config" + + source_functions + AUTO_YES=true + + run apply_ssh_config + assert_success + assert_output --partial "SSH config backed up" + + # Verify backup file exists + local backup_count + backup_count="$(find "${TEST_HOME}/.ssh" -name 'config.pre-harden-*' | wc -l | tr -d ' ')" + [ "$backup_count" -eq 1 ] + + # Verify backup contains original content + local backup_file + backup_file="$(find "${TEST_HOME}/.ssh" -name 'config.pre-harden-*' -print -quit)" + grep -q "StrictHostKeyChecking ask" "$backup_file" +} + +@test "apply_ssh_config does not create backup for new SSH config" { + rm -f "${TEST_HOME}/.ssh/config" + + source_functions + AUTO_YES=true + + run apply_ssh_config + assert_success + refute_output --partial "SSH config backed up" +} + +# =========================================================================== +# v0.5.0: Dedicated signing key names +# =========================================================================== + +@test "detect_existing_keys finds dedicated signing key" { + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q + + source_functions + detect_existing_keys + + [ "$SIGNING_KEY_FOUND" = true ] + [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_signing.pub" ] +} + +@test "detect_existing_keys falls back to general key when no signing key" { + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q + + source_functions + detect_existing_keys + + [ "$SIGNING_KEY_FOUND" = true ] + [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519.pub" ] +} + +@test "-y mode enables signing with dedicated signing key" { + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q + + source_functions + AUTO_YES=true + + run apply_signing_config + assert_success + + [ "$(git config --global commit.gpgsign)" = "true" ] + local sigkey + sigkey="$(git config --global user.signingkey)" + [[ "$sigkey" = *"id_ed25519_signing.pub"* ]] +} + +# =========================================================================== +# v0.5.0: core.hooksPath separate prompt +# =========================================================================== + +@test "-y mode applies core.hooksPath separately from filesystem group" { + source_functions + AUTO_YES=true + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + + run apply_git_config + assert_success + + [ "$(git config --global core.hooksPath)" = "~/.config/git/hooks" ] +} + +@test "-y mode skips core.hooksPath when already set" { + git config --global core.hooksPath "~/.config/git/hooks" + + source_functions + AUTO_YES=true + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + + run apply_git_config + assert_success + refute_output --partial "Global Hooks Path" +} + +# =========================================================================== +# v0.5.0: reset-signing cleans configured key path +# =========================================================================== + +@test "reset-signing cleans actual configured key path" { + # Create a custom-named key + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/my_org_key" -N "" -q + git config --global user.signingkey "${TEST_HOME}/.ssh/my_org_key.pub" + git config --global commit.gpgsign true + + source_functions + AUTO_YES=true + + run reset_signing + assert_success + + # git config entries should be removed + local sigkey + sigkey="$(git config --global --get user.signingkey 2>/dev/null || true)" + [ -z "$sigkey" ] + + # Key files should be listed for cleanup + assert_output --partial "my_org_key" +} + +@test "reset-signing includes dedicated signing key names" { + # Create dedicated signing keys + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q + + source_functions + AUTO_YES=true + + run reset_signing + assert_success + assert_output --partial "id_ed25519_signing" +} + +# =========================================================================== +# v0.5.0: Version bump +# =========================================================================== + +@test "--version reports 0.5.0" { + run bash "$SCRIPT" --version + assert_output --partial "0.5.0" } diff --git a/test/interactive/test-identity-guard.sh b/test/interactive/test-identity-guard.sh new file mode 100755 index 0000000..d54dcbe --- /dev/null +++ b/test/interactive/test-identity-guard.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Interactive test: identity guard prevents useConfigOnly lockout +# Verifies: when user.name/email are missing, the script prompts for them +# before enabling useConfigOnly; after providing both, useConfigOnly is set. + +set -o errexit +set -o nounset +set -o pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=helpers.sh +source "${SCRIPT_DIR}/helpers.sh" + +main() { + trap cleanup EXIT + + printf 'Test: Identity guard — missing name/email\n' >&2 + + # Remove identity AND useConfigOnly so the guard triggers + git config --global --unset user.name 2>/dev/null || true + git config --global --unset user.email 2>/dev/null || true + git config --global --unset user.useConfigOnly 2>/dev/null || true + + # Remove signing keys so wizard shows options (not existing key prompt) + rm -f "${HOME}/.ssh/id_ed25519_signing" "${HOME}/.ssh/id_ed25519_signing.pub" + rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub" + + start_session + + # Safety review gate + wait_for "reviewed this script" + send "y" Enter + + # Proceed with hardening + wait_for "Proceed with hardening" + send "y" Enter + + # Accept settings until identity guard prompt appears + local pane_content + for _ in $(seq 1 50); do + sleep 0.3 + pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)" + if printf '%s' "$pane_content" | grep -qF "Enter your name"; then + break + fi + if printf '%s' "$pane_content" | grep -qF "Hardening complete"; then + fail "Identity guard did not trigger — reached completion" + exit 1 + fi + send "y" Enter + done + + # Identity guard: enter name + wait_for "Enter your name" 15 + send "Test User" Enter + + # Identity guard: enter email + wait_for "Enter your email" 10 + send "test@example.com" Enter + + # Continue accepting remaining prompts + for _ in $(seq 1 50); do + sleep 0.3 + pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)" + if printf '%s' "$pane_content" | grep -qF "Signing key options"; then + break + fi + if printf '%s' "$pane_content" | grep -qF "Hardening complete"; then + break + fi + send "y" Enter + done + + # Skip signing + if tmux capture-pane -t "$TMUX_SESSION" -p | grep -qF "Signing key options"; then + send "s" Enter + fi + + # Wait for completion + sleep 2 + capture_output >/dev/null 2>&1 || true + + # Verify: useConfigOnly was set + local use_config_only + use_config_only="$(git config --global --get user.useConfigOnly 2>/dev/null || true)" + if [ "$use_config_only" = "true" ]; then + pass "Identity guard: useConfigOnly=true set after providing name/email" + else + fail "Identity guard: useConfigOnly not set (expected true, got '${use_config_only}')" + exit 1 + fi + + # Verify: name and email were set + local name email + name="$(git config --global --get user.name 2>/dev/null || true)" + email="$(git config --global --get user.email 2>/dev/null || true)" + if [ "$name" = "Test User" ] && [ "$email" = "test@example.com" ]; then + pass "Identity guard: user.name and user.email configured" + else + fail "Identity guard: identity not configured (name='${name}', email='${email}')" + exit 1 + fi +} + +main diff --git a/test/interactive/test-signing-generate.sh b/test/interactive/test-signing-generate.sh index 8ad2185..aa4c710 100755 --- a/test/interactive/test-signing-generate.sh +++ b/test/interactive/test-signing-generate.sh @@ -16,7 +16,12 @@ main() { printf 'Test: Signing wizard - generate ed25519 key\n' >&2 - # Ensure no existing keys + # Ensure identity is set (prior tests may have cleared it) + git config --global user.name "Test User" 2>/dev/null || true + git config --global user.email "test@example.com" 2>/dev/null || true + + # Ensure no existing signing keys (new dedicated names + legacy) + rm -f "${HOME}/.ssh/id_ed25519_signing" "${HOME}/.ssh/id_ed25519_signing.pub" rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub" start_session @@ -61,9 +66,9 @@ main() { sleep 3 capture_output >/dev/null 2>&1 || true - # Verify key exists - if [ -f "${HOME}/.ssh/id_ed25519.pub" ]; then - pass "Key generated: ~/.ssh/id_ed25519.pub exists" + # Verify key exists (new dedicated signing key name) + if [ -f "${HOME}/.ssh/id_ed25519_signing.pub" ]; then + pass "Key generated: ~/.ssh/id_ed25519_signing.pub exists" else fail "Key not generated" exit 1 diff --git a/test/interactive/test-signing-skip.sh b/test/interactive/test-signing-skip.sh index 99f5173..a1e855e 100755 --- a/test/interactive/test-signing-skip.sh +++ b/test/interactive/test-signing-skip.sh @@ -17,6 +17,9 @@ main() { printf 'Test: Signing wizard - skip\n' >&2 # Remove any keys from prior tests so wizard shows key generation options + rm -f "${HOME}/.ssh/id_ed25519_signing" "${HOME}/.ssh/id_ed25519_signing.pub" + rm -f "${HOME}/.ssh/id_ed25519_sk_signing" "${HOME}/.ssh/id_ed25519_sk_signing.pub" + rm -f "${HOME}/.ssh/id_ecdsa_sk_signing" "${HOME}/.ssh/id_ecdsa_sk_signing.pub" rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub" rm -f "${HOME}/.ssh/id_ed25519_sk" "${HOME}/.ssh/id_ed25519_sk.pub" git config --global --unset user.signingkey 2>/dev/null || true