From c5bbe5b44afe9bbedf4aa5468b0f4aeb53d43b01 Mon Sep 17 00:00:00 2001 From: Flo Date: Sun, 5 Apr 2026 03:25:48 -0700 Subject: [PATCH] feat: UX hardening for edge cases and pre-existing configurations Guard user.useConfigOnly behind identity check, offer to unset conflicting pull.rebase, use dedicated signing key names to avoid colliding with auth keys, back up SSH config before changes, place new SSH directives in Host * blocks, and prompt for email in allowed_signers setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 18 +++++ git-harden.sh | 192 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 186 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae0bc4..6c5914f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.5.0] - 2026-04-05 + +### Added +- Identity guard: prompt for `user.name`/`user.email` before enabling `user.useConfigOnly=true` to prevent commit lockout +- Apply phase offers to unset `pull.rebase` when it conflicts with `pull.ff=only` +- SSH config backup (`~/.ssh/config.pre-harden-*`) before applying SSH directives +- `core.hooksPath` gets its own prompt with explicit warning about overriding per-repo hooks (husky, lefthook, pre-commit) +- Allowed signers setup prompts for email when `user.email` is not configured globally + +### Changed +- Signing keys use dedicated names (`id_ed25519_signing`, `id_ed25519_sk_signing`, `id_ecdsa_sk_signing`) to avoid colliding with existing authentication keys +- "Key already exists" messages changed from `[WARN]` to `[INFO]` with clearer guidance ("using existing key") +- 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 + +### Fixed +- `readonly VERSION` variable conflict when sourcing `/etc/os-release` (replaced `.` with `sed` parse) + ## [0.4.0] - 2026-04-04 ### Added diff --git a/git-harden.sh b/git-harden.sh index 76fcf21..c568def 100755 --- a/git-harden.sh +++ b/git-harden.sh @@ -10,7 +10,7 @@ IFS=$'\n\t' # ------------------------------------------------------------------------------ # Constants # ------------------------------------------------------------------------------ -readonly VERSION="0.4.0" +readonly VERSION="0.5.0" readonly BACKUP_DIR="${HOME}/.config/git" readonly HOOKS_DIR="${HOME}/.config/git/hooks" readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers" @@ -318,9 +318,8 @@ detect_credential_helper() { # Print distro-specific install hints for keychain credential storage. credential_install_hint() { local distro_id="" - if [ -f /etc/os-release ]; then - # shellcheck disable=SC1091 # os-release is a system file, not part of this project - distro_id="$(. /etc/os-release && printf '%s' "${ID:-}")" + if [[ -f /etc/os-release ]]; then + distro_id="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"')" fi printf ' %bTo store credentials in the OS keychain, install one of:%b\n' "$YELLOW" "$RESET" >&2 @@ -378,6 +377,14 @@ audit_git_config() { print_header "Identity" audit_git_setting "user.useConfigOnly" "true" + # Warn if useConfigOnly would lock out commits (no global identity) + local has_name has_email + has_name="$(git config --global --get user.name 2>/dev/null || true)" + has_email="$(git config --global --get user.email 2>/dev/null || true)" + if [[ -z "$has_name" || -z "$has_email" ]]; then + print_warn "user.name/user.email not set globally — useConfigOnly=true will block commits outside configured repos" + fi + print_header "Object Integrity" audit_git_setting "transfer.fsckObjects" "true" audit_git_setting "fetch.fsckObjects" "true" @@ -831,10 +838,24 @@ apply_git_config() { "core.protectNTFS" "true" "Block NTFS 8.3 short-name attacks" \ "core.protectHFS" "true" "Block HFS+ Unicode normalization attacks" \ "core.fsmonitor" "false" "Disable filesystem monitor (attack surface)" \ - "core.hooksPath" "$hooks_path_val" "Redirect hooks to central dir" \ "safe.bareRepository" "explicit" "Require --git-dir for bare repos" \ "submodule.recurse" "false" "Don't auto-recurse into submodules" + # core.hooksPath: separate prompt — this overrides ALL per-repo hooks + if setting_needs_change "core.hooksPath" "$hooks_path_val"; then + print_header "Global Hooks Path" + printf ' %bWarning:%b Setting core.hooksPath redirects ALL hook execution to a\n' "$YELLOW" "$RESET" >&2 + printf ' central directory. Per-repo hooks (.git/hooks/) will stop running.\n' >&2 + printf ' This includes hooks from frameworks like husky, lefthook, and pre-commit.\n\n' >&2 + printf ' Recommended: set this, then install a dispatch hook that calls per-repo\n' >&2 + printf ' hooks when present (this script installs a gitleaks hook there).\n\n' >&2 + printf ' core.hooksPath = %s\n\n' "$hooks_path_val" >&2 + if prompt_yn "Set core.hooksPath? (overrides per-repo hooks)"; then + git config --global core.hooksPath "$hooks_path_val" + print_info "Set core.hooksPath = $hooks_path_val" + fi + fi + # core.symlinks: interactive-only (may break symlink-dependent workflows) if [ "$AUTO_YES" = false ]; then local current_symlinks @@ -879,16 +900,72 @@ apply_git_config() { fi fi + # pull.rebase conflicts with pull.ff=only — offer to unset + local pull_rebase + pull_rebase="$(git config --global --get pull.rebase 2>/dev/null || true)" + if [[ -n "$pull_rebase" ]]; then + printf '\n %bpull.rebase = %s conflicts with pull.ff = only%b\n' "$YELLOW" "$pull_rebase" "$RESET" >&2 + printf ' With pull.ff=only, git already refuses non-fast-forward pulls.\n' >&2 + printf ' Having pull.rebase set alongside it causes confusing errors.\n\n' >&2 + if prompt_yn "Unset pull.rebase?"; then + git config --global --unset pull.rebase + print_info "Unset pull.rebase" + fi + fi + # --- Group 5: Credential, Identity & Defaults --- local cred_current cred_current="$(git config --global --get credential.helper 2>/dev/null || true)" - apply_setting_group "Identity, Credentials & Defaults" \ - "Prevent accidental identity, enforce secure credential storage." \ - "user.useConfigOnly" "true" "Block commits without explicit user.name/email" \ + apply_setting_group "Defaults & Visibility" \ + "Sensible defaults for new repositories and log output." \ "init.defaultBranch" "main" "Default branch name for new repos" \ "log.showSignature" "true" "Show signature status in git log" + # user.useConfigOnly needs a guard — it locks out commits without identity + if setting_needs_change "user.useConfigOnly" "true"; then + local has_name has_email + has_name="$(git config --global --get user.name 2>/dev/null || true)" + has_email="$(git config --global --get user.email 2>/dev/null || true)" + if [[ -z "$has_name" || -z "$has_email" ]]; then + print_header "Identity Guard" + printf ' %buseConfigOnly=true blocks commits without user.name and user.email.%b\n' "$YELLOW" "$RESET" >&2 + printf ' You are missing: %s\n\n' \ + "$( [[ -z "$has_name" ]] && printf 'user.name '; [[ -z "$has_email" ]] && printf 'user.email' )" >&2 + if [[ -z "$has_name" ]]; then + printf ' Enter your name (or press Enter to skip): ' >&2 + local input_name + read -r input_name &2 + local input_email + read -r input_email /dev/null; then : # Already using a keychain-backed helper — leave it alone @@ -1075,9 +1152,9 @@ detect_existing_keys() { fi fi - # Check common ed25519 key locations (sk first, then software) + # Check common ed25519 key locations (dedicated signing keys first, then general) local priv_path pub_path - for key_type in id_ed25519_sk id_ed25519; do + for key_type in id_ed25519_sk_signing id_ecdsa_sk_signing id_ed25519_signing id_ed25519_sk id_ed25519; do priv_path="${SSH_DIR}/${key_type}" pub_path="${priv_path}.pub" if [ -f "$pub_path" ]; then @@ -1230,14 +1307,35 @@ reset_signing() { print_info "No signing key in git config" fi - # Collect all signing key files that would block fresh generation + # Collect signing key files: start with the actual configured path (if any), + # then check well-known names (dedicated signing keys + legacy defaults) local key_files=() local candidate + local seen_paths="" + + # Include the actual configured key and its private counterpart + if [[ -n "$signing_key" ]]; then + local configured_path="${signing_key/#\~/$HOME}" + for candidate in "$configured_path" "${configured_path%.pub}"; do + if [[ -f "$candidate" ]] && [[ "$seen_paths" != *"|${candidate}|"* ]]; then + key_files+=("$candidate") + seen_paths="${seen_paths}|${candidate}|" + fi + done + fi + + # Also check well-known signing key names for candidate in \ + "${SSH_DIR}/id_ed25519_sk_signing" "${SSH_DIR}/id_ed25519_sk_signing.pub" \ + "${SSH_DIR}/id_ecdsa_sk_signing" "${SSH_DIR}/id_ecdsa_sk_signing.pub" \ + "${SSH_DIR}/id_ed25519_signing" "${SSH_DIR}/id_ed25519_signing.pub" \ "${SSH_DIR}/id_ed25519_sk" "${SSH_DIR}/id_ed25519_sk.pub" \ "${SSH_DIR}/id_ecdsa_sk" "${SSH_DIR}/id_ecdsa_sk.pub" \ "${SSH_DIR}/id_ed25519" "${SSH_DIR}/id_ed25519.pub"; do - [ -f "$candidate" ] && key_files+=("$candidate") + if [[ -f "$candidate" ]] && [[ "$seen_paths" != *"|${candidate}|"* ]]; then + key_files+=("$candidate") + seen_paths="${seen_paths}|${candidate}|" + fi done if (( ${#key_files[@]} > 0 )); then @@ -1279,12 +1377,11 @@ enable_signing() { } generate_ssh_key() { - local key_path="${SSH_DIR}/id_ed25519" + local key_path="${SSH_DIR}/id_ed25519_signing" if [ -f "$key_path" ]; then - print_warn "$key_path already exists. Not overwriting." + print_info "$key_path already exists — using existing key" SIGNING_KEY_FOUND=true - SIGNING_PUB_PATH="${key_path}.pub" return fi @@ -1350,18 +1447,18 @@ detect_fido2_sk_type() { } generate_fido2_key() { - # Check for existing hardware-backed keys (both types) - local key_path_ed="${SSH_DIR}/id_ed25519_sk" - local key_path_ec="${SSH_DIR}/id_ecdsa_sk" + # Check for existing hardware-backed signing keys (both types) + local key_path_ed="${SSH_DIR}/id_ed25519_sk_signing" + local key_path_ec="${SSH_DIR}/id_ecdsa_sk_signing" if [ -f "$key_path_ed" ]; then - print_warn "$key_path_ed already exists. Not overwriting." + print_info "$key_path_ed already exists — using existing key" SIGNING_KEY_FOUND=true SIGNING_PUB_PATH="${key_path_ed}.pub" return fi if [ -f "$key_path_ec" ]; then - print_warn "$key_path_ec already exists. Not overwriting." + print_info "$key_path_ec already exists — using existing key" SIGNING_KEY_FOUND=true SIGNING_PUB_PATH="${key_path_ec}.pub" return @@ -1574,9 +1671,17 @@ setup_allowed_signers() { local email email="$(git config --global --get user.email 2>/dev/null || true)" - if [ -z "$email" ]; then - print_warn "user.email not set — cannot create allowed_signers entry" - return + if [[ -z "$email" ]]; then + printf ' %ballowed_signers requires an email to match signatures.%b\n' "$YELLOW" "$RESET" >&2 + printf ' Enter your email (or press Enter to skip): ' >&2 + local input_email + read -r input_email > "$SSH_CONFIG" + # Append inside a Host * block so it applies globally. + # If no Host * block exists, prepend one before the first Host/Match block + # (or append to EOF if the file has no blocks at all). + if grep -qE '^[[:space:]]*Host[[:space:]]+\*[[:space:]]*$' "$SSH_CONFIG" 2>/dev/null; then + # Insert after the "Host *" line + local tmpfile + tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")" + local inserted=false + while IFS= read -r line || [[ -n "$line" ]]; do + printf '%s\n' "$line" + if [[ "$inserted" = false ]] && printf '%s' "$line" | grep -qE '^[[:space:]]*Host[[:space:]]+\*[[:space:]]*$'; then + printf ' %s %s\n' "$directive" "$value" + inserted=true + fi + done < "$SSH_CONFIG" > "$tmpfile" + mv "$tmpfile" "$SSH_CONFIG" + chmod 600 "$SSH_CONFIG" + elif grep -qEi '^[[:space:]]*(Host|Match)[[:space:]]' "$SSH_CONFIG" 2>/dev/null; then + # File has Host/Match blocks but no Host *. Prepend a Host * section. + local tmpfile + tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")" + { + printf 'Host *\n' + printf ' %s %s\n' "$directive" "$value" + printf '\n' + cat "$SSH_CONFIG" + } > "$tmpfile" + mv "$tmpfile" "$SSH_CONFIG" + chmod 600 "$SSH_CONFIG" + else + # No blocks at all — safe to append bare + printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG" + fi fi } @@ -1699,6 +1836,13 @@ apply_ssh_config() { touch "$SSH_CONFIG" chmod 600 "$SSH_CONFIG" print_info "Created $SSH_CONFIG with mode 600" + else + # Back up existing SSH config before modifying + local timestamp + timestamp="$(date +%Y%m%d-%H%M%S)" + local ssh_backup="${SSH_CONFIG}.pre-harden-${timestamp}" + cp -p "$SSH_CONFIG" "$ssh_backup" + print_info "SSH config backed up to $ssh_backup" fi apply_ssh_directive_group "Host Verification" \