feat: v0.2.0 expanded hardening
Add gitleaks pre-commit hook, global gitignore, plaintext credential detection, SSH key hygiene audit, 8 new git config settings, and safe.directory wildcard detection. Fix ssh-keygen macOS compatibility, FIDO2 detection via ioreg, and interactive test isolation. Implements docs/specs/2026-03-31-v0.2.0-expanded-hardening.md Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
473
git-harden.sh
473
git-harden.sh
@@ -10,10 +10,11 @@ IFS=$'\n\t'
|
||||
# ------------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ------------------------------------------------------------------------------
|
||||
readonly VERSION="0.1.0"
|
||||
readonly VERSION="0.2.0"
|
||||
readonly BACKUP_DIR="${HOME}/.config/git"
|
||||
readonly HOOKS_DIR="${HOME}/.config/git/hooks"
|
||||
readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers"
|
||||
readonly GLOBAL_GITIGNORE="${HOME}/.config/git/ignore"
|
||||
readonly SSH_DIR="${HOME}/.ssh"
|
||||
readonly SSH_CONFIG="${SSH_DIR}/config"
|
||||
|
||||
@@ -307,12 +308,18 @@ audit_git_setting() {
|
||||
}
|
||||
|
||||
audit_git_config() {
|
||||
print_header "Identity"
|
||||
audit_git_setting "user.useConfigOnly" "true"
|
||||
|
||||
print_header "Object Integrity"
|
||||
audit_git_setting "transfer.fsckObjects" "true"
|
||||
audit_git_setting "fetch.fsckObjects" "true"
|
||||
audit_git_setting "receive.fsckObjects" "true"
|
||||
audit_git_setting "transfer.bundleURI" "false"
|
||||
audit_git_setting "fetch.prune" "true"
|
||||
|
||||
print_header "Protocol Restrictions"
|
||||
audit_git_setting "protocol.version" "2"
|
||||
audit_git_setting "protocol.allow" "never"
|
||||
audit_git_setting "protocol.https.allow" "always"
|
||||
audit_git_setting "protocol.ssh.allow" "always"
|
||||
@@ -324,6 +331,7 @@ audit_git_config() {
|
||||
audit_git_setting "core.protectNTFS" "true"
|
||||
audit_git_setting "core.protectHFS" "true"
|
||||
audit_git_setting "core.fsmonitor" "false"
|
||||
audit_git_setting "core.symlinks" "false"
|
||||
|
||||
print_header "Hook Control"
|
||||
# shellcheck disable=SC2088 # Intentional: git config stores literal ~
|
||||
@@ -333,6 +341,13 @@ audit_git_config() {
|
||||
audit_git_setting "safe.bareRepository" "explicit"
|
||||
audit_git_setting "submodule.recurse" "false"
|
||||
|
||||
# Detect dangerous safe.directory = * wildcard (CVE-2022-24765)
|
||||
local safe_dirs
|
||||
safe_dirs="$(git config --global --get-all safe.directory 2>/dev/null || true)"
|
||||
if printf '%s\n' "$safe_dirs" | grep -qx '\*'; then
|
||||
print_warn "safe.directory = * disables ownership checks (CVE-2022-24765). Remove this setting."
|
||||
fi
|
||||
|
||||
print_header "Pull/Merge Hardening"
|
||||
audit_git_setting "pull.ff" "only"
|
||||
audit_git_setting "merge.ff" "only"
|
||||
@@ -372,10 +387,181 @@ audit_git_config() {
|
||||
print_warn "credential.helper = $cred_current (expected: $DETECTED_CRED_HELPER)"
|
||||
fi
|
||||
|
||||
print_header "Defaults"
|
||||
audit_git_setting "init.defaultBranch" "main"
|
||||
|
||||
print_header "Forensic Readiness"
|
||||
audit_git_setting "gc.reflogExpire" "180.days"
|
||||
audit_git_setting "gc.reflogExpireUnreachable" "90.days"
|
||||
|
||||
print_header "Visibility"
|
||||
audit_git_setting "log.showSignature" "true"
|
||||
}
|
||||
|
||||
audit_precommit_hook() {
|
||||
print_header "Pre-commit Hook"
|
||||
|
||||
local hook_path="${HOOKS_DIR}/pre-commit"
|
||||
|
||||
if [ ! -f "$hook_path" ]; then
|
||||
print_miss "No pre-commit hook at $hook_path"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ ! -x "$hook_path" ]; then
|
||||
print_warn "Pre-commit hook exists but is not executable: $hook_path"
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -q 'gitleaks' "$hook_path" 2>/dev/null; then
|
||||
print_ok "Pre-commit hook with gitleaks at $hook_path"
|
||||
else
|
||||
print_warn "Pre-commit hook exists but does not reference gitleaks (user-managed)"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_global_gitignore() {
|
||||
print_header "Global Gitignore"
|
||||
|
||||
local excludes_file
|
||||
excludes_file="$(git config --global --get core.excludesFile 2>/dev/null || true)"
|
||||
|
||||
if [ -z "$excludes_file" ]; then
|
||||
print_miss "core.excludesFile (no global gitignore configured)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Expand tilde
|
||||
local expanded_path
|
||||
expanded_path="${excludes_file/#\~/$HOME}"
|
||||
|
||||
if [ ! -f "$expanded_path" ]; then
|
||||
print_warn "core.excludesFile = $excludes_file (file does not exist)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check for key security patterns
|
||||
local has_security_patterns=false
|
||||
if grep -q '\.env' "$expanded_path" 2>/dev/null && \
|
||||
grep -q '\*\.pem' "$expanded_path" 2>/dev/null; then
|
||||
has_security_patterns=true
|
||||
fi
|
||||
|
||||
if [ "$has_security_patterns" = true ]; then
|
||||
print_ok "core.excludesFile = $excludes_file (contains security patterns)"
|
||||
else
|
||||
print_warn "core.excludesFile = $excludes_file (lacks secret patterns: .env, *.pem, *.key — consider adding them)"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_credential_hygiene() {
|
||||
print_header "Credential Hygiene"
|
||||
|
||||
# shellcheck disable=SC2088 # Intentional: ~ used as display text
|
||||
# ~/.git-credentials — plaintext git passwords
|
||||
if [ -f "${HOME}/.git-credentials" ]; then
|
||||
print_warn "~/.git-credentials exists (plaintext git credentials — migrate to credential helper and delete this file)"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2088 # Intentional: ~ used as display text
|
||||
# ~/.netrc — plaintext network credentials
|
||||
if [ -f "${HOME}/.netrc" ]; then
|
||||
print_warn "~/.netrc exists (plaintext network credentials — may contain git hosting tokens)"
|
||||
fi
|
||||
|
||||
# ~/.npmrc — check for actual auth tokens
|
||||
if [ -f "${HOME}/.npmrc" ]; then
|
||||
if grep -qE '_authToken=.+' "${HOME}/.npmrc" 2>/dev/null; then
|
||||
# shellcheck disable=SC2088 # Intentional: ~ used as display text
|
||||
print_warn "~/.npmrc contains auth token (plaintext npm registry token — use env vars instead)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ~/.pypirc — check for password field
|
||||
if [ -f "${HOME}/.pypirc" ]; then
|
||||
if grep -qE '^[[:space:]]*password' "${HOME}/.pypirc" 2>/dev/null; then
|
||||
# shellcheck disable=SC2088 # Intentional: ~ used as display text
|
||||
print_warn "~/.pypirc contains password (plaintext PyPI credentials — use keyring or token-based auth)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
audit_ssh_key_hygiene() {
|
||||
print_header "SSH Key Hygiene"
|
||||
|
||||
local pub_files=()
|
||||
local seen_files=""
|
||||
|
||||
# Collect ~/.ssh/*.pub files
|
||||
local f
|
||||
for f in "${SSH_DIR}"/*.pub; do
|
||||
[ -f "$f" ] || continue
|
||||
pub_files+=("$f")
|
||||
seen_files="${seen_files}|${f}"
|
||||
done
|
||||
|
||||
# Also collect keys from IdentityFile directives in ~/.ssh/config
|
||||
if [ -f "$SSH_CONFIG" ]; then
|
||||
local identity_path
|
||||
while IFS= read -r identity_path; do
|
||||
identity_path="$(strip_ssh_value "$identity_path")"
|
||||
[ -z "$identity_path" ] && continue
|
||||
identity_path="${identity_path/#\~/$HOME}"
|
||||
local pub_path="${identity_path}.pub"
|
||||
if [ -f "$pub_path" ]; then
|
||||
# Skip if already seen
|
||||
case "$seen_files" in
|
||||
*"|${pub_path}"*) continue ;;
|
||||
esac
|
||||
pub_files+=("$pub_path")
|
||||
seen_files="${seen_files}|${pub_path}"
|
||||
fi
|
||||
done <<EOF
|
||||
$(grep -i '^[[:space:]]*IdentityFile[[:space:]=]' "$SSH_CONFIG" 2>/dev/null | sed 's/^[[:space:]]*[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]=]*//')
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ ${#pub_files[@]} -eq 0 ]; then
|
||||
print_info "No SSH public keys found"
|
||||
return
|
||||
fi
|
||||
|
||||
local key_type bits label
|
||||
for f in "${pub_files[@]}"; do
|
||||
key_type="$(awk '{print $1}' "$f" 2>/dev/null || true)"
|
||||
label="$(basename "$f")"
|
||||
|
||||
case "$key_type" in
|
||||
ssh-ed25519)
|
||||
print_ok "SSH key $label (ed25519)"
|
||||
;;
|
||||
sk-ssh-ed25519@openssh.com|sk-ssh-ed25519*)
|
||||
print_ok "SSH key $label (ed25519-sk, hardware-backed)"
|
||||
;;
|
||||
sk-ecdsa-sha2-nistp256@openssh.com|sk-ecdsa-sha2*)
|
||||
print_ok "SSH key $label (ecdsa-sk, hardware-backed)"
|
||||
;;
|
||||
ssh-rsa)
|
||||
bits="$(ssh-keygen -l -f "$f" 2>/dev/null | awk '{print $1}' || true)"
|
||||
if [ -n "$bits" ] && [ "$bits" -lt 2048 ] 2>/dev/null; then
|
||||
print_warn "SSH key $label (RSA ${bits}-bit — weak, migrate to ed25519 immediately)"
|
||||
else
|
||||
print_warn "SSH key $label (RSA ${bits:-?}-bit — consider migrating to ed25519)"
|
||||
fi
|
||||
;;
|
||||
ssh-dss)
|
||||
print_warn "SSH key $label (DSA — deprecated, migrate to ed25519)"
|
||||
;;
|
||||
ecdsa-sha2-*)
|
||||
print_warn "SSH key $label (ECDSA — consider migrating to ed25519)"
|
||||
;;
|
||||
*)
|
||||
print_info "SSH key $label (unknown type: $key_type)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
audit_signing() {
|
||||
print_header "Signing Configuration"
|
||||
|
||||
@@ -417,7 +603,7 @@ audit_ssh_directive() {
|
||||
local expected="$2"
|
||||
|
||||
local current
|
||||
current="$(grep -i "^[[:space:]]*${directive}[[:space:]]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^ ]*[[:space:]]*//' || true)"
|
||||
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 [ -z "$current" ]; then
|
||||
@@ -511,12 +697,18 @@ apply_git_setting() {
|
||||
apply_git_config() {
|
||||
print_header "Applying Git Config Hardening"
|
||||
|
||||
# Identity
|
||||
apply_git_setting "user.useConfigOnly" "true"
|
||||
|
||||
# Object integrity
|
||||
apply_git_setting "transfer.fsckObjects" "true"
|
||||
apply_git_setting "fetch.fsckObjects" "true"
|
||||
apply_git_setting "receive.fsckObjects" "true"
|
||||
apply_git_setting "transfer.bundleURI" "false"
|
||||
apply_git_setting "fetch.prune" "true"
|
||||
|
||||
# Protocol restrictions
|
||||
apply_git_setting "protocol.version" "2"
|
||||
apply_git_setting "protocol.allow" "never"
|
||||
apply_git_setting "protocol.https.allow" "always"
|
||||
apply_git_setting "protocol.ssh.allow" "always"
|
||||
@@ -529,6 +721,18 @@ apply_git_config() {
|
||||
apply_git_setting "core.protectHFS" "true"
|
||||
apply_git_setting "core.fsmonitor" "false"
|
||||
|
||||
# core.symlinks: interactive-only (may break symlink-dependent workflows)
|
||||
if [ "$AUTO_YES" = false ]; then
|
||||
local current_symlinks
|
||||
current_symlinks="$(git config --global --get core.symlinks 2>/dev/null || true)"
|
||||
if [ "$current_symlinks" != "false" ]; then
|
||||
if prompt_yn "Disable symlinks to prevent symlink-based attacks (CVE-2024-32002)? Note: may break projects that use symlinks (e.g. Node.js monorepos)."; then
|
||||
git config --global core.symlinks false
|
||||
print_info "Set core.symlinks = false"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Hook control
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
# shellcheck disable=SC2088 # Intentional: git config stores literal ~
|
||||
@@ -538,6 +742,17 @@ apply_git_config() {
|
||||
apply_git_setting "safe.bareRepository" "explicit"
|
||||
apply_git_setting "submodule.recurse" "false"
|
||||
|
||||
# Remove dangerous safe.directory = * wildcard if present
|
||||
local safe_dirs
|
||||
safe_dirs="$(git config --global --get-all safe.directory 2>/dev/null || true)"
|
||||
if printf '%s\n' "$safe_dirs" | grep -qx '\*'; then
|
||||
if prompt_yn "Remove dangerous safe.directory = * (disables ownership checks, CVE-2022-24765)?"; then
|
||||
git config --global --unset 'safe.directory' '\*' 2>/dev/null || \
|
||||
git config --global --unset-all 'safe.directory' '\*' 2>/dev/null || true
|
||||
print_info "Removed safe.directory = *"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Pull/merge hardening
|
||||
apply_git_setting "pull.ff" "only"
|
||||
apply_git_setting "merge.ff" "only"
|
||||
@@ -568,10 +783,132 @@ apply_git_config() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Defaults
|
||||
apply_git_setting "init.defaultBranch" "main"
|
||||
|
||||
# Forensic readiness
|
||||
apply_git_setting "gc.reflogExpire" "180.days"
|
||||
apply_git_setting "gc.reflogExpireUnreachable" "90.days"
|
||||
|
||||
# Visibility
|
||||
apply_git_setting "log.showSignature" "true"
|
||||
}
|
||||
|
||||
apply_precommit_hook() {
|
||||
print_header "Pre-commit Hook (gitleaks)"
|
||||
|
||||
local hook_path="${HOOKS_DIR}/pre-commit"
|
||||
|
||||
# Never overwrite existing hooks
|
||||
if [ -f "$hook_path" ]; then
|
||||
if grep -q 'gitleaks' "$hook_path" 2>/dev/null; then
|
||||
return
|
||||
fi
|
||||
print_info "Existing pre-commit hook found — not overwriting"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check for gitleaks
|
||||
local has_gitleaks=false
|
||||
if command -v gitleaks >/dev/null 2>&1; then
|
||||
has_gitleaks=true
|
||||
fi
|
||||
|
||||
if [ "$has_gitleaks" = false ]; then
|
||||
print_warn "gitleaks not found — install it for pre-commit secret scanning:"
|
||||
printf ' macOS: brew install gitleaks\n' >&2
|
||||
printf ' Linux: brew install gitleaks (or download from GitHub releases)\n' >&2
|
||||
fi
|
||||
|
||||
if prompt_yn "Install gitleaks pre-commit hook at $hook_path?"; then
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
cat > "$hook_path" << 'HOOK_EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Installed by git-harden.sh — global pre-commit secret scanning
|
||||
# To bypass for a single commit: SKIP_GITLEAKS=1 git commit
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
set -o pipefail
|
||||
|
||||
if [ "${SKIP_GITLEAKS:-0}" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if command -v gitleaks >/dev/null 2>&1; then
|
||||
gitleaks protect --staged --redact --verbose
|
||||
fi
|
||||
HOOK_EOF
|
||||
chmod +x "$hook_path"
|
||||
print_info "Installed gitleaks pre-commit hook at $hook_path"
|
||||
fi
|
||||
}
|
||||
|
||||
apply_global_gitignore() {
|
||||
print_header "Global Gitignore"
|
||||
|
||||
local excludes_file
|
||||
excludes_file="$(git config --global --get core.excludesFile 2>/dev/null || true)"
|
||||
|
||||
if [ -n "$excludes_file" ]; then
|
||||
local expanded_path
|
||||
expanded_path="${excludes_file/#\~/$HOME}"
|
||||
print_info "core.excludesFile already set to $excludes_file"
|
||||
if [ -f "$expanded_path" ]; then
|
||||
local has_security_patterns=false
|
||||
if grep -q '\.env' "$expanded_path" 2>/dev/null && \
|
||||
grep -q '\*\.pem' "$expanded_path" 2>/dev/null; then
|
||||
has_security_patterns=true
|
||||
fi
|
||||
if [ "$has_security_patterns" = false ]; then
|
||||
print_warn "Your global gitignore lacks secret patterns (.env, *.pem, *.key) — consider adding them"
|
||||
fi
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if prompt_yn "Create global gitignore with security patterns at $GLOBAL_GITIGNORE?"; then
|
||||
mkdir -p "$(dirname "$GLOBAL_GITIGNORE")"
|
||||
cat > "$GLOBAL_GITIGNORE" << 'GITIGNORE_EOF'
|
||||
# === Security: secrets & credentials ===
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
*.jks
|
||||
credentials.json
|
||||
service-account*.json
|
||||
.git-credentials
|
||||
.netrc
|
||||
.npmrc
|
||||
.pypirc
|
||||
|
||||
# === Security: Terraform state (contains secrets) ===
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
|
||||
# === OS artifacts ===
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# === IDE artifacts ===
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
GITIGNORE_EOF
|
||||
print_info "Created $GLOBAL_GITIGNORE"
|
||||
|
||||
# shellcheck disable=SC2088 # Intentional: git config stores literal ~
|
||||
git config --global core.excludesFile "~/.config/git/ignore"
|
||||
print_info "Set core.excludesFile = ~/.config/git/ignore"
|
||||
fi
|
||||
}
|
||||
|
||||
apply_signing_config() {
|
||||
print_header "Signing Configuration"
|
||||
|
||||
@@ -586,12 +923,7 @@ apply_signing_config() {
|
||||
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
|
||||
enable_signing "$SIGNING_PUB_PATH"
|
||||
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."
|
||||
@@ -660,22 +992,41 @@ detect_existing_keys() {
|
||||
esac
|
||||
fi
|
||||
done <<EOF
|
||||
$(grep -i '^[[:space:]]*IdentityFile[[:space:]]' "$SSH_CONFIG" 2>/dev/null | sed 's/^[[:space:]]*[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]*//')
|
||||
$(grep -i '^[[:space:]]*IdentityFile[[:space:]=]' "$SSH_CONFIG" 2>/dev/null | sed 's/^[[:space:]]*[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]=]*//')
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
detect_fido2_hardware() {
|
||||
# Check via ykman (cross-platform)
|
||||
if [ "$HAS_YKMAN" = true ]; then
|
||||
if ykman info >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Check via fido2-token (Linux)
|
||||
if [ "$HAS_FIDO2_TOKEN" = true ]; then
|
||||
if fido2-token -L 2>/dev/null | grep -q .; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# macOS: check IOKit USB registry for FIDO devices (works without ykman)
|
||||
if [ "$PLATFORM" = "macos" ]; then
|
||||
if ioreg -p IOUSB -l 2>/dev/null | grep -qi "fido\|yubikey\|security key\|titan"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Linux: check /sys for FIDO HID devices (Yubico vendor 1050)
|
||||
if [ "$PLATFORM" = "linux" ]; then
|
||||
if [ -d /sys/bus/hid/devices ]; then
|
||||
for dev_dir in /sys/bus/hid/devices/*; do
|
||||
[ -d "$dev_dir" ] || continue
|
||||
case "$(basename "$dev_dir")" in
|
||||
*1050:*) return 0 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -690,32 +1041,20 @@ signing_wizard() {
|
||||
|
||||
if [ "$SIGNING_KEY_FOUND" = true ]; then
|
||||
printf '\n 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
|
||||
if prompt_yn "Use this key for git signing? (enables commit + tag signing)"; then
|
||||
enable_signing "$SIGNING_PUB_PATH"
|
||||
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 ' 2) Generate a new ed25519-sk SSH key (FIDO2 hardware key)\n' >&2
|
||||
printf ' s) Skip signing setup\n' >&2
|
||||
|
||||
local choice
|
||||
printf '\n Choose [1%s/s]: ' "$(if [ "$has_fido2" = true ]; then printf '/2'; fi)" >&2
|
||||
printf '\n Choose [1/2/s]: ' >&2
|
||||
read -r choice </dev/tty || choice="s"
|
||||
|
||||
case "$choice" in
|
||||
@@ -723,12 +1062,7 @@ signing_wizard() {
|
||||
generate_ssh_key
|
||||
;;
|
||||
2)
|
||||
if [ "$has_fido2" = true ]; then
|
||||
generate_fido2_key
|
||||
else
|
||||
print_warn "FIDO2 not available. Skipping."
|
||||
return
|
||||
fi
|
||||
generate_fido2_key
|
||||
;;
|
||||
*)
|
||||
print_info "Skipping signing setup."
|
||||
@@ -737,15 +1071,24 @@ signing_wizard() {
|
||||
esac
|
||||
|
||||
if [ "$SIGNING_KEY_FOUND" = true ]; 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
|
||||
if prompt_yn "Enable commit and tag signing with this key?"; then
|
||||
enable_signing "$SIGNING_PUB_PATH"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Enable signing with a given public key path. Sets signingkey, gpgsign,
|
||||
# and forceSignAnnotated in one step (no individual prompts).
|
||||
enable_signing() {
|
||||
local pub_path="$1"
|
||||
git config --global user.signingkey "$pub_path"
|
||||
git config --global commit.gpgsign true
|
||||
git config --global tag.gpgsign true
|
||||
git config --global tag.forceSignAnnotated true
|
||||
print_info "Signing enabled: commits and tags will be signed with $pub_path"
|
||||
setup_allowed_signers
|
||||
}
|
||||
|
||||
generate_ssh_key() {
|
||||
local key_path="${SSH_DIR}/id_ed25519"
|
||||
|
||||
@@ -769,7 +1112,7 @@ generate_ssh_key() {
|
||||
mkdir -p "$SSH_DIR"
|
||||
chmod 700 "$SSH_DIR"
|
||||
|
||||
ssh-keygen -t ed25519 -C "$email" -f -- "$key_path" </dev/tty
|
||||
ssh-keygen -t ed25519 -C "$email" -f "$key_path" </dev/tty
|
||||
|
||||
if [ -f "${key_path}.pub" ]; then
|
||||
SIGNING_KEY_FOUND=true
|
||||
@@ -792,6 +1135,40 @@ generate_fido2_key() {
|
||||
return
|
||||
fi
|
||||
|
||||
if ! detect_fido2_hardware; then
|
||||
printf '\n No FIDO2 security key detected.\n' >&2
|
||||
printf ' Please insert your security key and press Enter to continue (or q to go back): ' >&2
|
||||
local reply
|
||||
read -r reply </dev/tty || reply="q"
|
||||
if [ "$reply" = "q" ]; then
|
||||
return
|
||||
fi
|
||||
if ! detect_fido2_hardware; then
|
||||
print_warn "Still no FIDO2 hardware detected. Skipping."
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Detect FIDO2 middleware library (required on macOS)
|
||||
local sk_provider=""
|
||||
if [ "$PLATFORM" = "macos" ]; then
|
||||
local provider_path
|
||||
for provider_path in \
|
||||
/opt/homebrew/lib/libsk-libfido2.dylib \
|
||||
/usr/local/lib/libsk-libfido2.dylib; do
|
||||
if [ -f "$provider_path" ]; then
|
||||
sk_provider="$provider_path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$sk_provider" ]; then
|
||||
print_warn "FIDO2 middleware not found. macOS requires libfido2 for hardware key support."
|
||||
printf ' Install with: brew install libfido2\n' >&2
|
||||
printf ' Then re-run this script.\n' >&2
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
printf ' Generating ed25519-sk SSH key (touch your security key when prompted)...\n' >&2
|
||||
|
||||
local email
|
||||
@@ -804,8 +1181,14 @@ generate_fido2_key() {
|
||||
mkdir -p "$SSH_DIR"
|
||||
chmod 700 "$SSH_DIR"
|
||||
|
||||
# Pass -w <provider> on macOS; on Linux the built-in support usually works
|
||||
local keygen_args=(-t ed25519-sk -C "$email" -f "$key_path")
|
||||
if [ -n "$sk_provider" ]; then
|
||||
keygen_args+=(-w "$sk_provider")
|
||||
fi
|
||||
|
||||
# Do NOT suppress stderr — per AC-7
|
||||
ssh-keygen -t ed25519-sk -C "$email" -f -- "$key_path" </dev/tty
|
||||
ssh-keygen "${keygen_args[@]}" </dev/tty
|
||||
|
||||
if [ -f "${key_path}.pub" ]; then
|
||||
SIGNING_KEY_FOUND=true
|
||||
@@ -856,7 +1239,7 @@ apply_ssh_directive() {
|
||||
|
||||
# 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)"
|
||||
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
|
||||
@@ -873,7 +1256,7 @@ apply_ssh_directive() {
|
||||
# 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
|
||||
if [ "$replaced" = false ] && printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]=]"; then
|
||||
printf '%s %s\n' "$directive" "$value"
|
||||
replaced=true
|
||||
else
|
||||
@@ -924,7 +1307,7 @@ apply_ssh_config() {
|
||||
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 branch protection rules on main 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
|
||||
@@ -983,8 +1366,12 @@ main() {
|
||||
AUDIT_MISS=0
|
||||
|
||||
audit_git_config
|
||||
audit_precommit_hook
|
||||
audit_global_gitignore
|
||||
audit_credential_hygiene
|
||||
audit_signing
|
||||
audit_ssh_config
|
||||
audit_ssh_key_hygiene
|
||||
|
||||
local audit_exit=0
|
||||
print_audit_report || audit_exit=$?
|
||||
@@ -1011,6 +1398,8 @@ main() {
|
||||
|
||||
backup_git_config
|
||||
apply_git_config
|
||||
apply_precommit_hook
|
||||
apply_global_gitignore
|
||||
apply_signing_config
|
||||
apply_ssh_config
|
||||
print_admin_recommendations
|
||||
|
||||
Reference in New Issue
Block a user