From ca4daa153904a0b53f6014aa540c72533ef4d89a Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 3 Apr 2026 09:14:57 -0700 Subject: [PATCH] feat: group SSH config directives with trade-off explanations SSH config hardening now presents directives in logical groups (matching the git config UX) with reasoning for each: - Host Verification: TOFU rationale, known_hosts exfiltration risk - Key & Agent Management: key enumeration attack, passphrase fatigue - Algorithm Restrictions: downgrade attack, intentional RSA breakage Each group batches its directives into a single prompt instead of asking one-by-one. Bump version to 0.3.1. Co-Authored-By: Claude Opus 4.6 (1M context) --- git-harden.sh | 136 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 101 insertions(+), 35 deletions(-) diff --git a/git-harden.sh b/git-harden.sh index 10e5156..ec19975 100755 --- a/git-harden.sh +++ b/git-harden.sh @@ -10,7 +10,7 @@ IFS=$'\n\t' # ------------------------------------------------------------------------------ # Constants # ------------------------------------------------------------------------------ -readonly VERSION="0.3.0" +readonly VERSION="0.3.1" readonly BACKUP_DIR="${HOME}/.config/git" readonly HOOKS_DIR="${HOME}/.config/git/hooks" readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers" @@ -1552,46 +1552,95 @@ setup_allowed_signers() { # SSH config hardening # ------------------------------------------------------------------------------ -apply_ssh_directive() { +ssh_directive_needs_change() { local directive="$1" local value="$2" - # Check if directive already exists with correct value (case-insensitive directive match) local current current="$(grep -i "^[[:space:]]*${directive}[[:space:]=]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^[:space:]=]*[[:space:]=]*//' || true)" current="$(strip_ssh_value "$current")" - if [ "$current" = "$value" ]; then - return - fi + [ "$current" != "$value" ] +} + +apply_single_ssh_directive() { + local directive="$1" + local value="$2" + + local current + current="$(grep -i "^[[:space:]]*${directive}[[:space:]=]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^[:space:]=]*[[:space:]=]*//' || true)" + current="$(strip_ssh_value "$current")" if [ -n "$current" ]; then - # Directive exists but with wrong value - if prompt_yn "Update SSH directive: $directive $current -> $value?"; then - # Use temp file to avoid sed -i portability issues - local tmpfile - tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")" - trap 'rm -f "$tmpfile"' EXIT - # Replace first occurrence of the directive (case-insensitive) - local replaced=false - while IFS= read -r line || [ -n "$line" ]; do - if [ "$replaced" = false ] && printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]=]"; then - printf '%s %s\n' "$directive" "$value" - replaced=true - else - printf '%s\n' "$line" - fi - done < "$SSH_CONFIG" > "$tmpfile" - mv "$tmpfile" "$SSH_CONFIG" - chmod 600 "$SSH_CONFIG" - print_info "Updated $directive = $value in $SSH_CONFIG" - fi + # Replace existing directive + local tmpfile + tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")" + local replaced=false + while IFS= read -r line || [ -n "$line" ]; do + if [ "$replaced" = false ] && printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]=]"; then + printf '%s %s\n' "$directive" "$value" + replaced=true + else + printf '%s\n' "$line" + fi + done < "$SSH_CONFIG" > "$tmpfile" + mv "$tmpfile" "$SSH_CONFIG" + chmod 600 "$SSH_CONFIG" else - # Directive missing entirely - if prompt_yn "Add SSH directive: $directive $value?"; then - printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG" - print_info "Added $directive $value to $SSH_CONFIG" + printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG" + fi +} + +apply_ssh_directive_group() { + local group_name="$1" + local description="$2" + shift 2 + + # Collect pending changes (directives that need updating) + local pending_keys="" + local pending_vals="" + local pending_explanations="" + local count=0 + + while [ $# -ge 3 ]; do + local key="$1" value="$2" explanation="$3" + shift 3 + if ssh_directive_needs_change "$key" "$value"; then + pending_keys="${pending_keys}${key}"$'\n' + pending_vals="${pending_vals}${value}"$'\n' + pending_explanations="${pending_explanations}${explanation}"$'\n' + count=$((count + 1)) fi + done + + if [ "$count" -eq 0 ]; then + return 0 + fi + + printf '\n %b%s%b\n' "$BOLD" "$group_name" "$RESET" >&2 + printf ' %s\n\n' "$description" >&2 + + local i=0 + while [ "$i" -lt "$count" ]; do + local key val expl + key="$(printf '%s' "$pending_keys" | sed -n "$((i + 1))p")" + val="$(printf '%s' "$pending_vals" | sed -n "$((i + 1))p")" + expl="$(printf '%s' "$pending_explanations" | sed -n "$((i + 1))p")" + printf ' %-45s %s\n' "${key} ${val}" "# ${expl}" >&2 + i=$((i + 1)) + done + printf '\n' >&2 + + if prompt_yn "Apply these ${count} directives?"; then + i=0 + while [ "$i" -lt "$count" ]; do + local key val + key="$(printf '%s' "$pending_keys" | sed -n "$((i + 1))p")" + val="$(printf '%s' "$pending_vals" | sed -n "$((i + 1))p")" + apply_single_ssh_directive "$key" "$val" + i=$((i + 1)) + done + print_info "Applied ${count} SSH directives" fi } @@ -1612,11 +1661,28 @@ apply_ssh_config() { print_info "Created $SSH_CONFIG with mode 600" fi - apply_ssh_directive "StrictHostKeyChecking" "accept-new" - apply_ssh_directive "HashKnownHosts" "yes" - apply_ssh_directive "IdentitiesOnly" "yes" - apply_ssh_directive "AddKeysToAgent" "yes" - apply_ssh_directive "PubkeyAcceptedAlgorithms" "ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com" + apply_ssh_directive_group "Host Verification" \ + "Trust-on-first-use (TOFU): accept new host keys automatically, but reject + changed keys (the actual MITM scenario). The default 'ask' just trains users + to blindly type 'yes'. Hashing known_hosts prevents hostname enumeration if + the file is exfiltrated." \ + "StrictHostKeyChecking" "accept-new" "Auto-accept new hosts, reject changed keys" \ + "HashKnownHosts" "yes" "Hash hostnames in known_hosts (privacy)" + + apply_ssh_directive_group "Key & Agent Management" \ + "Without IdentitiesOnly, ssh-agent offers ALL loaded keys to every server — + a malicious server can enumerate which services you have access to. + AddKeysToAgent reduces passphrase fatigue so developers actually use them." \ + "IdentitiesOnly" "yes" "Only offer keys explicitly configured (prevents key leakage)" \ + "AddKeysToAgent" "yes" "Auto-add keys to ssh-agent after first use" + + apply_ssh_directive_group "Algorithm Restrictions" \ + "Disables RSA and DSA negotiation entirely. This prevents downgrade attacks + to weaker algorithms. May break connections to legacy servers that only + support RSA — those servers should be upgraded (RSA-SHA1 deprecated since + OpenSSH 8.7)." \ + "PubkeyAcceptedAlgorithms" "ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com" \ + "Ed25519 + ECDSA (software and hardware-backed)" } # ------------------------------------------------------------------------------