From 2a5302388e48f08d17ec0d2b6aefb15ef9256d04 Mon Sep 17 00:00:00 2001 From: Flo Date: Mon, 30 Mar 2026 13:38:34 +0200 Subject: [PATCH] feat(git-harden): implement git-harden.sh script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive shell script that audits and hardens global git config. Implements the design spec with: object integrity checks, protocol restrictions, filesystem protection, hook redirection, SSH signing wizard with FIDO2 support, SSH config hardening, and credential helper detection. Supports --audit, -y, and interactive modes. Implements: #5 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .gitignore | 32 ++ AGENTS.md | 40 +++ CLAUDE.md | 1 + git-harden.sh | 956 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1029 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100755 git-harden.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f26d7a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# === Crosslink managed (do not edit between markers) === +# .crosslink/ — machine-local state (never commit) +.crosslink/issues.db +.crosslink/issues.db-wal +.crosslink/issues.db-shm +.crosslink/agent.json +.crosslink/session.json +.crosslink/daemon.pid +.crosslink/daemon.log +.crosslink/last_test_run +.crosslink/keys/ +.crosslink/.hub-cache/ +.crosslink/.knowledge-cache/ +.crosslink/.cache/ +.crosslink/hook-config.local.json +.crosslink/integrations/ +.crosslink/rules.local/ + +# .crosslink/ — DO track these (project-level policy): +# .crosslink/hook-config.json — shared team configuration +# .crosslink/rules/ — project coding standards +# .crosslink/.gitignore — inner gitignore for agent files + +# .claude/ — auto-generated by crosslink init (not project source) +.claude/hooks/ +.claude/commands/ +.claude/mcp/ + +# .claude/ — DO track these (if manually configured): +# .claude/settings.json — Claude Code project settings +# .claude/settings.local.json is per-developer, ignore separately if needed +# === End crosslink managed === diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a6e0a22 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +## Shell Script Development Standards (v2.0) + +If you're going to write shell scripts, at least try to make them look like a professional wrote them. The following standards are non-negotiable for `git-harden`. + +### 1. The Header: No More `sh` From the 80s +Use `bash` via `env` for portability. We need modern features like arrays and local scoping. +```bash +#!/usr/bin/env bash +set -o errexit # -e: Abort on nonzero exitstatus +set -o nounset # -u: Abort on unbound variable +set -o pipefail # Don't hide errors within pipes +IFS=$'\n\t' # Stop splitting on spaces like a maniac +``` + +### 2. Scoping & Immutability (Functional-ish) +- **Global Constants:** Always `readonly`. Use `UPPER_CASE`. +- **Functions:** Every variable MUST be `local`. No global state soup. +- **Returns:** Use `return` for status codes, `echo` to "return" data via command substitution. +- **Early Returns:** Guard clauses are your friend. Flatten the control flow. If I see more than 3 levels of indentation, I'm quitting. + +### 3. Syntax & Safety +- **Conditionals:** Always use `[[ ... ]]`, not `[ ... ]`. It's safer and less likely to blow up on empty strings. +- **Arithmetic:** Use `(( ... ))` for numeric comparisons and math. +- **Subshells:** Use `$(...)`, never backticks. It's not 1985. +- **Quoting:** Quote EVERYTHING. `"${var}"`, not `$var`. No exceptions. +- **Tool Checks:** Use `command -v tool_name` to check for dependencies. `which` is for people who don't care about portability. + +### 4. Logging & Error Handling +- **Die Early:** Use a `die()` function for fatal errors. +- **Stderr:** All logging (info, warn, error) goes to `stderr` (`>&2`). `stdout` is reserved for data/results. +- **XDG Compliance:** Respect `${XDG_CONFIG_HOME:-$HOME/.config}`. Don't just dump files in `$HOME`. +- **Temp Files:** Use `mktemp -t` or `mktemp -d`. Clean them up using a `trap`. + +### 5. Portability (The macOS/Linux Divide) +- Avoid `sed -i` (it's different on macOS and Linux). Use a temporary file and `mv`. +- Use `printf` instead of `echo -e` or `echo -n`. +- Test on both `bash` 3.2 (macOS default) and 5.x (modern Linux). + +### 6. Verification +- All scripts MUST pass `shellcheck`. If it's yellow or red, it's garbage. Fix it. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/git-harden.sh b/git-harden.sh new file mode 100755 index 0000000..a117466 --- /dev/null +++ b/git-harden.sh @@ -0,0 +1,956 @@ +#!/usr/bin/env bash +# git-harden.sh — Audit and harden global git configuration +# Usage: git-harden.sh [--audit] [-y] [--help] + +set -o errexit +set -o nounset +set -o pipefail +IFS=$'\n\t' + +# ------------------------------------------------------------------------------ +# Constants +# ------------------------------------------------------------------------------ +readonly VERSION="1.0.0" +readonly BACKUP_DIR="${HOME}/.config/git" +readonly HOOKS_DIR="${HOME}/.config/git/hooks" +readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers" +readonly SSH_DIR="${HOME}/.ssh" +readonly SSH_CONFIG="${SSH_DIR}/config" + +# Color codes (empty if not a terminal) +if [ -t 2 ]; then + readonly RED='\033[0;31m' + readonly GREEN='\033[0;32m' + readonly YELLOW='\033[0;33m' + readonly BLUE='\033[0;34m' + readonly BOLD='\033[1m' + readonly RESET='\033[0m' +else + readonly RED='' + readonly GREEN='' + readonly YELLOW='' + readonly BLUE='' + readonly BOLD='' + readonly RESET='' +fi + +# Mode flags (mutable — set by parse_args) +AUTO_YES=false +AUDIT_ONLY=false +PLATFORM="" + +# Audit counters +AUDIT_OK=0 +AUDIT_WARN=0 +AUDIT_MISS=0 + +# Whether signing key was found +SIGNING_KEY_FOUND=false +export SIGNING_KEY_PATH="" # private key path; exported for hook/subshell use +SIGNING_PUB_PATH="" + +# Credential helper detected for this platform +DETECTED_CRED_HELPER="" + +# Optional tool availability +HAS_YKMAN=false +HAS_FIDO2_TOKEN=false + +# ------------------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------------------ + +die() { + printf '%bError:%b %s\n' "$RED" "$RESET" "$1" >&2 + exit 1 +} + +print_ok() { + printf '%b[OK]%b %s\n' "$GREEN" "$RESET" "$1" >&2 + AUDIT_OK=$((AUDIT_OK + 1)) +} + +print_warn() { + printf '%b[WARN]%b %s\n' "$YELLOW" "$RESET" "$1" >&2 + AUDIT_WARN=$((AUDIT_WARN + 1)) +} + +print_miss() { + printf '%b[MISS]%b %s\n' "$RED" "$RESET" "$1" >&2 + AUDIT_MISS=$((AUDIT_MISS + 1)) +} + +print_info() { + printf '%b[INFO]%b %s\n' "$BLUE" "$RESET" "$1" >&2 +} + +print_header() { + printf '\n%b── %s ──%b\n' "$BOLD" "$1" "$RESET" >&2 +} + +prompt_yn() { + local prompt="$1" + local default="${2:-y}" + + if [ "$AUTO_YES" = true ]; then + return 0 + fi + + local yn_hint + if [ "$default" = "y" ]; then + yn_hint="[Y/n]" + else + yn_hint="[y/N]" + fi + + local answer + printf '%s %s ' "$prompt" "$yn_hint" >&2 + read -r answer &2 <<'EOF' +Usage: git-harden.sh [OPTIONS] + +Audit and harden your global git configuration. + +Options: + --audit Run audit only (no changes), exit 0 if all OK, 2 if issues found + -y, --yes Auto-apply all recommended settings (no prompts) + --help, -h Show this help message + --version Show version + +Exit codes: + 0 All settings OK, or changes successfully applied + 1 Error (missing dependencies, etc.) + 2 Audit found issues (--audit mode only) +EOF +} + +# ------------------------------------------------------------------------------ +# Platform detection +# ------------------------------------------------------------------------------ + +detect_platform() { + local uname_out + uname_out="$(uname -s)" + case "$uname_out" in + Darwin*) PLATFORM="macos" ;; + Linux*) PLATFORM="linux" ;; + *) die "Unsupported platform: $uname_out" ;; + esac +} + +# Compare version strings: returns 0 if $1 >= $2 +version_gte() { + local IFS_SAVE="$IFS" + IFS='.' + # shellcheck disable=SC2086 + set -- $1 $2 + IFS="$IFS_SAVE" + local a1="${1:-0}" a2="${2:-0}" a3="${3:-0}" + local b1="${4:-0}" b2="${5:-0}" b3="${6:-0}" + + if [ "$a1" -gt "$b1" ] 2>/dev/null; then return 0; fi + if [ "$a1" -lt "$b1" ] 2>/dev/null; then return 1; fi + if [ "$a2" -gt "$b2" ] 2>/dev/null; then return 0; fi + if [ "$a2" -lt "$b2" ] 2>/dev/null; then return 1; fi + if [ "$a3" -ge "$b3" ] 2>/dev/null; then return 0; fi + return 1 +} + +check_dependencies() { + # git required + if ! command -v git >/dev/null 2>&1; then + die "git is not installed" + fi + + local git_version + git_version="$(git --version | sed 's/[^0-9.]//g')" + if ! version_gte "$git_version" "2.34.0"; then + die "git >= 2.34.0 required (found $git_version)" + fi + + # ssh-keygen required + if ! command -v ssh-keygen >/dev/null 2>&1; then + die "ssh-keygen is not installed" + fi + + # Optional: ykman + if command -v ykman >/dev/null 2>&1; then + HAS_YKMAN=true + fi + + # Optional: fido2-token + if command -v fido2-token >/dev/null 2>&1; then + HAS_FIDO2_TOKEN=true + fi + + # Detect credential helper + detect_credential_helper +} + +detect_credential_helper() { + case "$PLATFORM" in + macos) + DETECTED_CRED_HELPER="osxkeychain" + ;; + linux) + # Try to find libsecret credential helper + local libsecret_path="" + for path in \ + /usr/lib/git-core/git-credential-libsecret \ + /usr/libexec/git-core/git-credential-libsecret \ + /usr/lib/git/git-credential-libsecret; do + if [ -x "$path" ]; then + libsecret_path="$path" + break + fi + done + + if [ -n "$libsecret_path" ]; then + DETECTED_CRED_HELPER="$libsecret_path" + else + DETECTED_CRED_HELPER="cache --timeout=3600" + fi + ;; + esac +} + +# ------------------------------------------------------------------------------ +# Audit functions +# ------------------------------------------------------------------------------ + +# Check a single git config key against expected value. +# Returns: prints status, updates counters. +audit_git_setting() { + local key="$1" + local expected="$2" + local label="${3:-$key}" + + local current + current="$(git config --global --get "$key" 2>/dev/null || true)" + + if [ -z "$current" ]; then + print_miss "$label (expected: $expected)" + elif [ "$current" = "$expected" ]; then + print_ok "$label = $current" + else + print_warn "$label = $current (expected: $expected)" + fi +} + +audit_git_config() { + print_header "Object Integrity" + audit_git_setting "transfer.fsckObjects" "true" + audit_git_setting "fetch.fsckObjects" "true" + audit_git_setting "receive.fsckObjects" "true" + + print_header "Protocol Restrictions" + audit_git_setting "protocol.allow" "never" + audit_git_setting "protocol.https.allow" "always" + audit_git_setting "protocol.ssh.allow" "always" + audit_git_setting "protocol.file.allow" "user" + audit_git_setting "protocol.git.allow" "never" + audit_git_setting "protocol.ext.allow" "never" + + print_header "Filesystem Protection" + audit_git_setting "core.protectNTFS" "true" + audit_git_setting "core.protectHFS" "true" + audit_git_setting "core.fsmonitor" "false" + + print_header "Hook Control" + # shellcheck disable=SC2088 # Intentional: git config stores literal ~ + audit_git_setting "core.hooksPath" "~/.config/git/hooks" + + print_header "Repository Safety" + audit_git_setting "safe.bareRepository" "explicit" + audit_git_setting "submodule.recurse" "false" + + print_header "Pull/Merge Hardening" + audit_git_setting "pull.ff" "only" + audit_git_setting "merge.ff" "only" + + # AC-15: warn if pull.rebase is set (conflicts with pull.ff=only) + local pull_rebase + pull_rebase="$(git config --global --get pull.rebase 2>/dev/null || true)" + if [ -n "$pull_rebase" ]; then + print_warn "pull.rebase = $pull_rebase (conflicts with pull.ff=only — consider unsetting)" + fi + + print_header "Transport Security" + # url..insteadOf needs special handling + local instead_of + instead_of="$(git config --global --get 'url.https://.insteadOf' 2>/dev/null || true)" + if [ -z "$instead_of" ]; then + print_miss "url.\"https://\".insteadOf (expected: http://)" + elif [ "$instead_of" = "http://" ]; then + print_ok "url.\"https://\".insteadOf = http://" + else + print_warn "url.\"https://\".insteadOf = $instead_of (expected: http://)" + fi + + audit_git_setting "http.sslVerify" "true" + + print_header "Credential Storage" + local cred_current + cred_current="$(git config --global --get credential.helper 2>/dev/null || true)" + if [ -z "$cred_current" ]; then + print_miss "credential.helper (expected: $DETECTED_CRED_HELPER)" + elif [ "$cred_current" = "store" ]; then + print_warn "credential.helper = store (INSECURE: stores passwords in plaintext; expected: $DETECTED_CRED_HELPER)" + elif [ "$cred_current" = "$DETECTED_CRED_HELPER" ]; then + print_ok "credential.helper = $cred_current" + else + # Non-store, non-recommended — could be user's custom helper + print_warn "credential.helper = $cred_current (expected: $DETECTED_CRED_HELPER)" + fi + + print_header "Visibility" + audit_git_setting "log.showSignature" "true" +} + +audit_signing() { + print_header "Signing Configuration" + + audit_git_setting "gpg.format" "ssh" + # shellcheck disable=SC2088 # Intentional: git config stores literal ~ + audit_git_setting "gpg.ssh.allowedSignersFile" "~/.config/git/allowed_signers" + + # Check signing key + local signing_key + signing_key="$(git config --global --get user.signingkey 2>/dev/null || true)" + if [ -z "$signing_key" ]; then + print_miss "user.signingkey (no signing key configured)" + else + # Verify the key file exists + local expanded_key + expanded_key="${signing_key/#\~/$HOME}" + if [ -f "$expanded_key" ]; then + print_ok "user.signingkey = $signing_key" + else + # Key might be an inline key (starts with ssh-) + case "$signing_key" in + ssh-*|ecdsa-*|sk-*) + print_ok "user.signingkey = (inline key)" + ;; + *) + print_warn "user.signingkey = $signing_key (file not found)" + ;; + esac + fi + fi + + audit_git_setting "commit.gpgsign" "true" + audit_git_setting "tag.gpgsign" "true" + audit_git_setting "tag.forceSignAnnotated" "true" +} + +audit_ssh_directive() { + local directive="$1" + local expected="$2" + + local current + current="$(grep -i "^[[:space:]]*${directive}[[:space:]]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^ ]*[[:space:]]*//' || true)" + + if [ -z "$current" ]; then + print_miss "SSH: $directive (expected: $expected)" + elif [ "$current" = "$expected" ]; then + print_ok "SSH: $directive = $current" + else + print_warn "SSH: $directive = $current (expected: $expected)" + fi +} + +audit_ssh_config() { + print_header "SSH Configuration" + + if [ ! -f "$SSH_CONFIG" ]; then + print_miss "$SSH_CONFIG does not exist" + return + fi + + audit_ssh_directive "StrictHostKeyChecking" "accept-new" + audit_ssh_directive "HashKnownHosts" "yes" + audit_ssh_directive "IdentitiesOnly" "yes" + audit_ssh_directive "AddKeysToAgent" "yes" + audit_ssh_directive "PubkeyAcceptedAlgorithms" "ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com" +} + +print_audit_report() { + print_header "Audit Summary" + printf '%b %d OK / %d WARN / %d MISS%b\n' \ + "$BOLD" "$AUDIT_OK" "$AUDIT_WARN" "$AUDIT_MISS" "$RESET" >&2 + + if [ $((AUDIT_WARN + AUDIT_MISS)) -gt 0 ]; then + return 2 + fi + return 0 +} + +# ------------------------------------------------------------------------------ +# Apply functions +# ------------------------------------------------------------------------------ + +backup_git_config() { + local config_file="${HOME}/.gitconfig" + local xdg_config="${HOME}/.config/git/config" + + mkdir -p "$BACKUP_DIR" + + local timestamp + timestamp="$(date +%Y%m%d-%H%M%S)" + local backup_file="${BACKUP_DIR}/pre-harden-backup-${timestamp}.txt" + + { + echo "# git-harden.sh backup — $timestamp" + echo "# Global git config snapshot" + echo "" + if [ -f "$config_file" ]; then + echo "## ~/.gitconfig" + cat "$config_file" + echo "" + fi + if [ -f "$xdg_config" ]; then + echo "## ~/.config/git/config" + cat "$xdg_config" + echo "" + fi + echo "## git config --global --list" + git config --global --list 2>/dev/null || echo "(no global config)" + } > "$backup_file" + + print_info "Config backed up to $backup_file" +} + +apply_git_setting() { + local key="$1" + local value="$2" + local label="${3:-$key}" + + local current + current="$(git config --global --get "$key" 2>/dev/null || true)" + + if [ "$current" = "$value" ]; then + return 0 + fi + + if prompt_yn "Set $label = $value?"; then + git config --global "$key" "$value" + print_info "Set $label = $value" + fi +} + +apply_git_config() { + print_header "Applying Git Config Hardening" + + # Object integrity + apply_git_setting "transfer.fsckObjects" "true" + apply_git_setting "fetch.fsckObjects" "true" + apply_git_setting "receive.fsckObjects" "true" + + # Protocol restrictions + apply_git_setting "protocol.allow" "never" + apply_git_setting "protocol.https.allow" "always" + apply_git_setting "protocol.ssh.allow" "always" + apply_git_setting "protocol.file.allow" "user" + apply_git_setting "protocol.git.allow" "never" + apply_git_setting "protocol.ext.allow" "never" + + # Filesystem protection + apply_git_setting "core.protectNTFS" "true" + apply_git_setting "core.protectHFS" "true" + apply_git_setting "core.fsmonitor" "false" + + # Hook control + mkdir -p "$HOOKS_DIR" + # shellcheck disable=SC2088 # Intentional: git config stores literal ~ + apply_git_setting "core.hooksPath" "~/.config/git/hooks" + + # Repository safety + apply_git_setting "safe.bareRepository" "explicit" + apply_git_setting "submodule.recurse" "false" + + # Pull/merge hardening + apply_git_setting "pull.ff" "only" + apply_git_setting "merge.ff" "only" + + # Transport security + local instead_of + instead_of="$(git config --global --get 'url.https://.insteadOf' 2>/dev/null || true)" + if [ "$instead_of" != "http://" ]; then + if prompt_yn "Set url.\"https://\".insteadOf = http://?"; then + git config --global 'url.https://.insteadOf' 'http://' + print_info "Set url.\"https://\".insteadOf = http://" + fi + fi + + apply_git_setting "http.sslVerify" "true" + + # Credential storage + local cred_current + cred_current="$(git config --global --get credential.helper 2>/dev/null || true)" + if [ "$cred_current" != "$DETECTED_CRED_HELPER" ]; then + local cred_prompt="Set credential.helper = $DETECTED_CRED_HELPER?" + if [ "$cred_current" = "store" ]; then + cred_prompt="Replace INSECURE credential.helper=store with $DETECTED_CRED_HELPER?" + fi + if prompt_yn "$cred_prompt"; then + git config --global credential.helper "$DETECTED_CRED_HELPER" + print_info "Set credential.helper = $DETECTED_CRED_HELPER" + fi + fi + + # Visibility + apply_git_setting "log.showSignature" "true" +} + +apply_signing_config() { + print_header "Signing Configuration" + + # Always safe to set format and allowed signers + apply_git_setting "gpg.format" "ssh" + # shellcheck disable=SC2088 # Intentional: git config stores literal ~ + apply_git_setting "gpg.ssh.allowedSignersFile" "~/.config/git/allowed_signers" + + # Detect existing signing key + detect_existing_keys + + if [ "$AUTO_YES" = true ]; then + # In -y mode: only enable signing if key exists + if [ "$SIGNING_KEY_FOUND" = true ] && [ -n "$SIGNING_PUB_PATH" ] && [ -f "$SIGNING_PUB_PATH" ]; then + git config --global user.signingkey "$SIGNING_PUB_PATH" + print_info "Set user.signingkey = $SIGNING_PUB_PATH" + apply_git_setting "commit.gpgsign" "true" + apply_git_setting "tag.gpgsign" "true" + apply_git_setting "tag.forceSignAnnotated" "true" + setup_allowed_signers + else + print_info "No SSH signing key found. Skipping commit.gpgsign and tag.gpgsign." + print_info "Run git-harden.sh interactively (without -y) to set up signing." + fi + else + # Interactive mode: run the wizard + signing_wizard + fi +} + +detect_existing_keys() { + SIGNING_KEY_FOUND=false + SIGNING_KEY_PATH="" + SIGNING_PUB_PATH="" + + # Check if a signing key is already configured + local configured_key + configured_key="$(git config --global --get user.signingkey 2>/dev/null || true)" + if [ -n "$configured_key" ]; then + local expanded_key + expanded_key="${configured_key/#\~/$HOME}" + if [ -f "$expanded_key" ]; then + SIGNING_KEY_FOUND=true + SIGNING_PUB_PATH="$expanded_key" + # Derive private key path (remove .pub suffix if present) + SIGNING_KEY_PATH="${expanded_key%.pub}" + return + fi + fi + + # Check common ed25519 key locations (sk first, then software) + local priv_path pub_path + for key_type in id_ed25519_sk id_ed25519; do + priv_path="${SSH_DIR}/${key_type}" + pub_path="${priv_path}.pub" + if [ -f "$pub_path" ]; then + SIGNING_KEY_FOUND=true + SIGNING_KEY_PATH="$priv_path" + SIGNING_PUB_PATH="$pub_path" + return + fi + done + + # Check IdentityFile directives in ~/.ssh/config for custom-named keys + if [ -f "$SSH_CONFIG" ]; then + local identity_path + while IFS= read -r identity_path; do + # Expand tilde safely + identity_path="${identity_path/#\~/$HOME}" + # Strip leading/trailing whitespace + identity_path="$(printf '%s' "$identity_path" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -z "$identity_path" ] && continue + + pub_path="${identity_path}.pub" + if [ -f "$pub_path" ]; then + # Only use ed25519 or ed25519-sk keys for signing + local key_type_str + key_type_str="$(head -1 "$pub_path" 2>/dev/null || true)" + case "$key_type_str" in + ssh-ed25519*|sk-ssh-ed25519*) + SIGNING_KEY_FOUND=true + SIGNING_KEY_PATH="$identity_path" + SIGNING_PUB_PATH="$pub_path" + return + ;; + esac + fi + done </dev/null | sed 's/^[[:space:]]*[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]*//') +EOF + fi +} + +detect_fido2_hardware() { + if [ "$HAS_YKMAN" = true ]; then + if ykman info >/dev/null 2>&1; then + return 0 + fi + fi + if [ "$HAS_FIDO2_TOKEN" = true ]; then + if fido2-token -L 2>/dev/null | grep -q .; then + return 0 + fi + fi + return 1 +} + +signing_wizard() { + print_header "SSH Signing Setup Wizard" + + if [ "$SIGNING_KEY_FOUND" = true ]; then + printf ' Found existing key: %s\n' "$SIGNING_PUB_PATH" >&2 + if prompt_yn "Use this key for git signing?"; then + git config --global user.signingkey "$SIGNING_PUB_PATH" + print_info "Set user.signingkey = $SIGNING_PUB_PATH" + apply_git_setting "commit.gpgsign" "true" + apply_git_setting "tag.gpgsign" "true" + apply_git_setting "tag.forceSignAnnotated" "true" + setup_allowed_signers + return + fi + fi + + # Offer key generation options + local has_fido2=false + if detect_fido2_hardware; then + has_fido2=true + fi + + printf '\n Signing key options:\n' >&2 + printf ' 1) Generate a new ed25519 SSH key (software)\n' >&2 + if [ "$has_fido2" = true ]; then + printf ' 2) Generate a new ed25519-sk SSH key (FIDO2 hardware key)\n' >&2 + fi + printf ' s) Skip signing setup\n' >&2 + + local choice + printf '\n Choose [1%s/s]: ' "$(if [ "$has_fido2" = true ]; then printf '/2'; fi)" >&2 + read -r choice &2 + + local email + email="$(git config --global --get user.email 2>/dev/null || true)" + if [ -z "$email" ]; then + printf ' Enter email for key comment: ' >&2 + read -r email &2 + + local email + email="$(git config --global --get user.email 2>/dev/null || true)" + if [ -z "$email" ]; then + printf ' Enter email for key comment: ' >&2 + read -r email /dev/null; then + print_info "Signing key already in allowed_signers" + return + fi + fi + + printf '%s %s\n' "$email" "$pub_key" >> "$ALLOWED_SIGNERS_FILE" + print_info "Added signing key to $ALLOWED_SIGNERS_FILE" +} + +# ------------------------------------------------------------------------------ +# SSH config hardening +# ------------------------------------------------------------------------------ + +apply_ssh_directive() { + 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:]]*//' || true)" + + if [ "$current" = "$value" ]; then + return + fi + + 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")" + # 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 + 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" + fi + fi +} + +apply_ssh_config() { + print_header "SSH Config Hardening" + + # Ensure ~/.ssh/ exists with correct permissions + if [ ! -d "$SSH_DIR" ]; then + mkdir -p "$SSH_DIR" + chmod 700 "$SSH_DIR" + print_info "Created $SSH_DIR with mode 700" + fi + + # Ensure ~/.ssh/config exists with correct permissions + if [ ! -f "$SSH_CONFIG" ]; then + touch "$SSH_CONFIG" + chmod 600 "$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" +} + +# ------------------------------------------------------------------------------ +# Admin recommendations +# ------------------------------------------------------------------------------ + +print_admin_recommendations() { + print_header "Admin / Org-Level Recommendations" + printf ' These are informational and cannot be applied by this script:\n\n' >&2 + printf ' • Enable branch protection rules on main/master branches\n' >&2 + printf ' • Enable GitHub vigilant mode (Settings → SSH and GPG keys → Flag unsigned commits)\n' >&2 + printf ' • Restrict force-pushes (disable or limit to admins)\n' >&2 + printf ' • Rotate personal access tokens regularly; prefer fine-grained tokens\n' >&2 + printf ' • Use short-lived credentials (GitHub App tokens, OIDC) in CI/CD\n' >&2 + printf ' • Require signed commits via branch protection (Require signed commits)\n' >&2 + printf ' • Audit deploy keys and service account access quarterly\n' >&2 + printf ' • If using hook frameworks (husky, lefthook, pre-commit), pin versions and review changes\n' >&2 + printf '\n' >&2 +} + +# ------------------------------------------------------------------------------ +# Main +# ------------------------------------------------------------------------------ + +main() { + parse_args "$@" + detect_platform + check_dependencies + + # --- Audit phase --- + AUDIT_OK=0 + AUDIT_WARN=0 + AUDIT_MISS=0 + + audit_git_config + audit_signing + audit_ssh_config + + local audit_exit=0 + print_audit_report || audit_exit=$? + + if [ "$AUDIT_ONLY" = true ]; then + exit "$audit_exit" + fi + + # If everything is already OK, nothing to do + if [ "$audit_exit" -eq 0 ]; then + print_info "All settings already match recommendations. Nothing to do." + print_admin_recommendations + exit 0 + fi + + # --- Apply phase --- + if [ "$AUTO_YES" = false ]; then + printf '\n' >&2 + if ! prompt_yn "Proceed with hardening?"; then + print_info "Aborted." + exit 0 + fi + fi + + backup_git_config + apply_git_config + apply_signing_config + apply_ssh_config + print_admin_recommendations + + print_info "Hardening complete. Re-run with --audit to verify." +} + +main "$@"