Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69707b4475 | ||
|
|
ca4daa1539 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -4,9 +4,23 @@ 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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-04-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- GCM (Git Credential Manager) detection — preferred cross-platform credential helper
|
||||||
|
- `is_keychain_credential_helper()` recognizes osxkeychain, GCM, libsecret, and gnome-keyring
|
||||||
|
- Distro-specific install hints when no keychain-backed credential helper is found (Debian/Ubuntu, Fedora/RHEL, Arch, openSUSE, Alpine)
|
||||||
|
- Audit labels keychain-backed helpers as `(keychain-backed)` for clarity
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Harden step skips credential.helper prompt when user already has a keychain-backed helper
|
||||||
|
- Audit messaging improved: clearer descriptions for missing, insecure, and unknown helpers
|
||||||
|
- FIDO2 signing wizard, grouped SSH config directives, REASONING.md (prior unreleased work)
|
||||||
|
|
||||||
## [0.2.3] - 2026-03-31
|
## [0.2.3] - 2026-03-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Fix e2e.sh distro loop not splitting on spaces (#39)
|
||||||
- FIDO2 key generation on macOS — detect Homebrew's openssh via `ssh-sk-helper` (no freeze), use its `ssh-keygen` binary for hardware key generation
|
- FIDO2 key generation on macOS — detect Homebrew's openssh via `ssh-sk-helper` (no freeze), use its `ssh-keygen` binary for hardware key generation
|
||||||
- Linux gitleaks install hint now shows `apt`/`dnf` instead of `brew`
|
- Linux gitleaks install hint now shows `apt`/`dnf` instead of `brew`
|
||||||
- e2e test runner distro loop broken by `IFS` setting — use bash array
|
- e2e test runner distro loop broken by `IFS` setting — use bash array
|
||||||
@@ -17,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
## [0.2.0] - 2026-03-31
|
## [0.2.0] - 2026-03-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Add REASONING.md documenting trade-offs for each hardening default (#48)
|
||||||
- Gitleaks pre-commit hook installation — creates `~/.config/git/hooks/pre-commit` with `SKIP_GITLEAKS` bypass
|
- Gitleaks pre-commit hook installation — creates `~/.config/git/hooks/pre-commit` with `SKIP_GITLEAKS` bypass
|
||||||
- Global gitignore creation (`~/.config/git/ignore`) with security patterns (`.env`, `*.pem`, `*.key`, credentials, Terraform state)
|
- Global gitignore creation (`~/.config/git/ignore`) with security patterns (`.env`, `*.pem`, `*.key`, credentials, Terraform state)
|
||||||
- Audit of existing global gitignore for missing security patterns
|
- Audit of existing global gitignore for missing security patterns
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -139,6 +139,47 @@ The script prints (but does not apply) server/org-level recommendations:
|
|||||||
- Clone untrusted repos with `--no-recurse-submodules`
|
- Clone untrusted repos with `--no-recurse-submodules`
|
||||||
- Use separate signing keys per org to prevent cross-platform identity correlation (OSINT)
|
- Use separate signing keys per org to prevent cross-platform identity correlation (OSINT)
|
||||||
|
|
||||||
|
## Signing with FIDO2 hardware keys
|
||||||
|
|
||||||
|
The script includes an interactive wizard that:
|
||||||
|
|
||||||
|
1. Detects existing SSH keys (including custom-named keys from `~/.ssh/config`)
|
||||||
|
2. Detects FIDO2 hardware (YubiKey, etc.)
|
||||||
|
3. Offers two tiers:
|
||||||
|
- **Software SSH key** — use existing `ed25519` or generate one
|
||||||
|
- **FIDO2 hardware key** — generate `ed25519-sk` with touch-to-sign (if hardware detected)
|
||||||
|
4. Configures `user.signingkey`, `commit.gpgsign`, `tag.gpgsign`
|
||||||
|
5. Sets up `~/.config/git/allowed_signers` for local signature verification
|
||||||
|
|
||||||
|
These combinations of hardware and OS have been tested:
|
||||||
|
|
||||||
|
| Hardware | Firmware | OS | works? |
|
||||||
|
|----------|----------|----|--------|
|
||||||
|
| [Yubico Security Key USB C NFC](https://support.yubico.com/s/article/Security-Key-C-NFC) | 5.4.3 | macOS Tahoe | Yes |
|
||||||
|
| [Yubico Security Key USB C NFC](https://support.yubico.com/s/article/Security-Key-C-NFC) | 5.4.3 | Debian 13 Trixie | |
|
||||||
|
| [Yubico Security Key USB C NFC](https://support.yubico.com/s/article/Security-Key-C-NFC) | 5.4.3 | Fedora 42 | Yes |
|
||||||
|
| [Yubico Security Key USB A NFC](https://support.yubico.com/s/article/Security-Key-NFC) | 5.4.3 | macOS Tahoe | Yes |
|
||||||
|
| [Yubico Security Key USB A NFC](https://support.yubico.com/s/article/Security-Key-NFC) | 5.4.3 | Debian 13 Trixie | |
|
||||||
|
| [Yubico Security Key USB A NFC](https://support.yubico.com/s/article/Security-Key-NFC) | 5.4.3 | Fedora 42 | Yes |
|
||||||
|
| [Yubico Security Key USB A NFC](https://www.yubico.com/products/security-key-by-yubico/usb-a-nfc/) | 5.0.2 | macOS Tahoe | Yes |
|
||||||
|
| [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 | 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* |
|
||||||
|
| [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 |
|
||||||
|
| [SoloKeys Solo 1 Tap USB-A](https://solokeys.com/collections/all/products/solo-tap-usb-a-preorder) | | macOS Tahoe | Yes |
|
||||||
|
| [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 | |
|
||||||
|
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
252
git-harden.sh
252
git-harden.sh
@@ -10,7 +10,7 @@ IFS=$'\n\t'
|
|||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
readonly VERSION="0.3.0"
|
readonly VERSION="0.4.0"
|
||||||
readonly BACKUP_DIR="${HOME}/.config/git"
|
readonly BACKUP_DIR="${HOME}/.config/git"
|
||||||
readonly HOOKS_DIR="${HOME}/.config/git/hooks"
|
readonly HOOKS_DIR="${HOME}/.config/git/hooks"
|
||||||
readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers"
|
readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers"
|
||||||
@@ -266,13 +266,31 @@ check_dependencies() {
|
|||||||
detect_credential_helper
|
detect_credential_helper
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if a credential.helper value corresponds to a keychain-backed store.
|
||||||
|
# Returns 0 (true) if the helper stores credentials in the OS keychain.
|
||||||
|
is_keychain_credential_helper() {
|
||||||
|
local helper="$1"
|
||||||
|
case "$helper" in
|
||||||
|
osxkeychain|manager|manager-core) return 0 ;;
|
||||||
|
*git-credential-libsecret*) return 0 ;;
|
||||||
|
*git-credential-gnome-keyring*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
detect_credential_helper() {
|
detect_credential_helper() {
|
||||||
|
# Git Credential Manager (GCM) — cross-platform, preferred when available
|
||||||
|
if command -v git-credential-manager >/dev/null 2>&1; then
|
||||||
|
DETECTED_CRED_HELPER="manager"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
macos)
|
macos)
|
||||||
DETECTED_CRED_HELPER="osxkeychain"
|
DETECTED_CRED_HELPER="osxkeychain"
|
||||||
;;
|
;;
|
||||||
linux)
|
linux)
|
||||||
# Try to find libsecret credential helper
|
# Try libsecret (GNOME Keyring / KDE Wallet / any Secret Service provider)
|
||||||
local libsecret_path=""
|
local libsecret_path=""
|
||||||
for path in \
|
for path in \
|
||||||
/usr/lib/git-core/git-credential-libsecret \
|
/usr/lib/git-core/git-credential-libsecret \
|
||||||
@@ -286,10 +304,49 @@ detect_credential_helper() {
|
|||||||
|
|
||||||
if [ -n "$libsecret_path" ]; then
|
if [ -n "$libsecret_path" ]; then
|
||||||
DETECTED_CRED_HELPER="$libsecret_path"
|
DETECTED_CRED_HELPER="$libsecret_path"
|
||||||
else
|
return
|
||||||
DETECTED_CRED_HELPER="cache --timeout=3600"
|
|
||||||
print_info "libsecret not found; falling back to in-memory credential cache (1h TTL, not persistent)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Fallback: in-memory cache (not persistent across reboots)
|
||||||
|
DETECTED_CRED_HELPER="cache --timeout=3600"
|
||||||
|
print_info "No keychain-backed credential helper found; falling back to in-memory cache (1h TTL)"
|
||||||
|
credential_install_hint
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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:-}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf ' %bTo store credentials in the OS keychain, install one of:%b\n' "$YELLOW" "$RESET" >&2
|
||||||
|
case "$distro_id" in
|
||||||
|
ubuntu|debian|pop|linuxmint)
|
||||||
|
printf ' • libsecret: sudo apt install libsecret-1-dev git make && cd /usr/share/doc/git/contrib/credential/libsecret && sudo make\n' >&2
|
||||||
|
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
|
||||||
|
;;
|
||||||
|
fedora|rhel|centos|rocky|alma)
|
||||||
|
printf ' • libsecret: sudo dnf install git-credential-libsecret\n' >&2
|
||||||
|
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
|
||||||
|
;;
|
||||||
|
arch|manjaro|endeavouros)
|
||||||
|
printf ' • libsecret: sudo pacman -S libsecret\n' >&2
|
||||||
|
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
|
||||||
|
;;
|
||||||
|
opensuse*|suse*)
|
||||||
|
printf ' • libsecret: sudo zypper install git-credential-libsecret\n' >&2
|
||||||
|
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
|
||||||
|
;;
|
||||||
|
alpine)
|
||||||
|
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf ' • libsecret: install git-credential-libsecret via your package manager\n' >&2
|
||||||
|
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -387,14 +444,15 @@ audit_git_config() {
|
|||||||
local cred_current
|
local cred_current
|
||||||
cred_current="$(git config --global --get credential.helper 2>/dev/null || true)"
|
cred_current="$(git config --global --get credential.helper 2>/dev/null || true)"
|
||||||
if [ -z "$cred_current" ]; then
|
if [ -z "$cred_current" ]; then
|
||||||
print_miss "credential.helper (expected: $DETECTED_CRED_HELPER)"
|
print_miss "credential.helper not set (credentials won't be cached)"
|
||||||
elif [ "$cred_current" = "store" ]; then
|
elif [ "$cred_current" = "store" ]; then
|
||||||
print_warn "credential.helper = store (INSECURE: stores passwords in plaintext; expected: $DETECTED_CRED_HELPER)"
|
print_warn "credential.helper = store (INSECURE: stores passwords in plaintext ~/${cred_current})"
|
||||||
|
elif is_keychain_credential_helper "$cred_current"; then
|
||||||
|
print_ok "credential.helper = $cred_current (keychain-backed)"
|
||||||
elif [ "$cred_current" = "$DETECTED_CRED_HELPER" ]; then
|
elif [ "$cred_current" = "$DETECTED_CRED_HELPER" ]; then
|
||||||
print_ok "credential.helper = $cred_current"
|
print_ok "credential.helper = $cred_current"
|
||||||
else
|
else
|
||||||
# Non-store, non-recommended — could be user's custom helper
|
print_warn "credential.helper = $cred_current (not a known keychain-backed helper)"
|
||||||
print_warn "credential.helper = $cred_current (expected: $DETECTED_CRED_HELPER)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_header "Defaults"
|
print_header "Defaults"
|
||||||
@@ -703,22 +761,22 @@ apply_setting_group() {
|
|||||||
shift 2
|
shift 2
|
||||||
|
|
||||||
# Collect pending changes (settings that need updating)
|
# Collect pending changes (settings that need updating)
|
||||||
local pending_keys=""
|
local pending_keys=()
|
||||||
local pending_vals=""
|
local pending_vals=()
|
||||||
local pending_explanations=""
|
local pending_explanations=()
|
||||||
local count=0
|
|
||||||
|
|
||||||
while [ $# -ge 3 ]; do
|
while [ $# -ge 3 ]; do
|
||||||
local key="$1" value="$2" explanation="$3"
|
local key="$1" value="$2" explanation="$3"
|
||||||
shift 3
|
shift 3
|
||||||
if setting_needs_change "$key" "$value"; then
|
if setting_needs_change "$key" "$value"; then
|
||||||
pending_keys="${pending_keys}${key}"$'\n'
|
pending_keys+=("$key")
|
||||||
pending_vals="${pending_vals}${value}"$'\n'
|
pending_vals+=("$value")
|
||||||
pending_explanations="${pending_explanations}${explanation}"$'\n'
|
pending_explanations+=("$explanation")
|
||||||
count=$((count + 1))
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
local count="${#pending_keys[@]}"
|
||||||
|
|
||||||
# Nothing to do
|
# Nothing to do
|
||||||
if [ "$count" -eq 0 ]; then
|
if [ "$count" -eq 0 ]; then
|
||||||
return 0
|
return 0
|
||||||
@@ -728,25 +786,15 @@ apply_setting_group() {
|
|||||||
printf ' %s\n\n' "$description" >&2
|
printf ' %s\n\n' "$description" >&2
|
||||||
|
|
||||||
# Show what will change
|
# Show what will change
|
||||||
local i=0
|
local i
|
||||||
while [ "$i" -lt "$count" ]; do
|
for ((i = 0; i < count; i++)); do
|
||||||
local key val expl
|
printf ' %-40s %s\n' "${pending_keys[$i]} = ${pending_vals[$i]}" "# ${pending_explanations[$i]}" >&2
|
||||||
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 ' %-40s %s\n' "${key} = ${val}" "# ${expl}" >&2
|
|
||||||
i=$((i + 1))
|
|
||||||
done
|
done
|
||||||
printf '\n' >&2
|
printf '\n' >&2
|
||||||
|
|
||||||
if prompt_yn "Apply these ${count} settings?"; then
|
if prompt_yn "Apply these ${count} settings?"; then
|
||||||
i=0
|
for ((i = 0; i < count; i++)); do
|
||||||
while [ "$i" -lt "$count" ]; do
|
git config --global "${pending_keys[$i]}" "${pending_vals[$i]}"
|
||||||
local key val
|
|
||||||
key="$(printf '%s' "$pending_keys" | sed -n "$((i + 1))p")"
|
|
||||||
val="$(printf '%s' "$pending_vals" | sed -n "$((i + 1))p")"
|
|
||||||
git config --global "$key" "$val"
|
|
||||||
i=$((i + 1))
|
|
||||||
done
|
done
|
||||||
print_info "Applied ${count} settings"
|
print_info "Applied ${count} settings"
|
||||||
fi
|
fi
|
||||||
@@ -841,8 +889,10 @@ apply_git_config() {
|
|||||||
"init.defaultBranch" "main" "Default branch name for new repos" \
|
"init.defaultBranch" "main" "Default branch name for new repos" \
|
||||||
"log.showSignature" "true" "Show signature status in git log"
|
"log.showSignature" "true" "Show signature status in git log"
|
||||||
|
|
||||||
# Credential helper needs special logic (warn about 'store')
|
# Credential helper needs special logic — accept any keychain-backed helper
|
||||||
if [ "$cred_current" != "$DETECTED_CRED_HELPER" ]; then
|
if is_keychain_credential_helper "$cred_current" 2>/dev/null; then
|
||||||
|
: # Already using a keychain-backed helper — leave it alone
|
||||||
|
elif [ "$cred_current" != "$DETECTED_CRED_HELPER" ]; then
|
||||||
local cred_prompt="Set credential.helper = $DETECTED_CRED_HELPER?"
|
local cred_prompt="Set credential.helper = $DETECTED_CRED_HELPER?"
|
||||||
if [ "$cred_current" = "store" ]; then
|
if [ "$cred_current" = "store" ]; then
|
||||||
cred_prompt="Replace INSECURE credential.helper=store with $DETECTED_CRED_HELPER?"
|
cred_prompt="Replace INSECURE credential.helper=store with $DETECTED_CRED_HELPER?"
|
||||||
@@ -1021,8 +1071,6 @@ detect_existing_keys() {
|
|||||||
if [ -f "$expanded_key" ]; then
|
if [ -f "$expanded_key" ]; then
|
||||||
SIGNING_KEY_FOUND=true
|
SIGNING_KEY_FOUND=true
|
||||||
SIGNING_PUB_PATH="$expanded_key"
|
SIGNING_PUB_PATH="$expanded_key"
|
||||||
# Derive private key path (remove .pub suffix if present)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -1552,46 +1600,87 @@ setup_allowed_signers() {
|
|||||||
# SSH config hardening
|
# SSH config hardening
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
apply_ssh_directive() {
|
# Read the current value of an SSH config directive (empty if absent).
|
||||||
|
get_ssh_directive_value() {
|
||||||
|
local directive="$1"
|
||||||
|
local raw
|
||||||
|
raw="$(grep -i "^[[:space:]]*${directive}[[:space:]=]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^[:space:]=]*[[:space:]=]*//' || true)"
|
||||||
|
strip_ssh_value "$raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_directive_needs_change() {
|
||||||
|
local directive="$1"
|
||||||
|
local value="$2"
|
||||||
|
[ "$(get_ssh_directive_value "$directive")" != "$value" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_single_ssh_directive() {
|
||||||
local directive="$1"
|
local directive="$1"
|
||||||
local value="$2"
|
local value="$2"
|
||||||
|
|
||||||
# Check if directive already exists with correct value (case-insensitive directive match)
|
|
||||||
local current
|
local current
|
||||||
current="$(grep -i "^[[:space:]]*${directive}[[:space:]=]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^[:space:]=]*[[:space:]=]*//' || true)"
|
current="$(get_ssh_directive_value "$directive")"
|
||||||
current="$(strip_ssh_value "$current")"
|
|
||||||
|
|
||||||
if [ "$current" = "$value" ]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$current" ]; then
|
if [ -n "$current" ]; then
|
||||||
# Directive exists but with wrong value
|
# Replace existing directive
|
||||||
if prompt_yn "Update SSH directive: $directive $current -> $value?"; then
|
local tmpfile
|
||||||
# Use temp file to avoid sed -i portability issues
|
tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")"
|
||||||
local tmpfile
|
local replaced=false
|
||||||
tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")"
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
trap 'rm -f "$tmpfile"' EXIT
|
if [ "$replaced" = false ] && printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]=]"; then
|
||||||
# Replace first occurrence of the directive (case-insensitive)
|
printf '%s %s\n' "$directive" "$value"
|
||||||
local replaced=false
|
replaced=true
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
else
|
||||||
if [ "$replaced" = false ] && printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]=]"; then
|
printf '%s\n' "$line"
|
||||||
printf '%s %s\n' "$directive" "$value"
|
fi
|
||||||
replaced=true
|
done < "$SSH_CONFIG" > "$tmpfile"
|
||||||
else
|
mv "$tmpfile" "$SSH_CONFIG"
|
||||||
printf '%s\n' "$line"
|
chmod 600 "$SSH_CONFIG"
|
||||||
fi
|
|
||||||
done < "$SSH_CONFIG" > "$tmpfile"
|
|
||||||
mv "$tmpfile" "$SSH_CONFIG"
|
|
||||||
chmod 600 "$SSH_CONFIG"
|
|
||||||
print_info "Updated $directive = $value in $SSH_CONFIG"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
# Directive missing entirely
|
printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG"
|
||||||
if prompt_yn "Add SSH directive: $directive $value?"; then
|
fi
|
||||||
printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG"
|
}
|
||||||
print_info "Added $directive $value to $SSH_CONFIG"
|
|
||||||
|
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=()
|
||||||
|
|
||||||
|
while [ $# -ge 3 ]; do
|
||||||
|
local key="$1" value="$2" explanation="$3"
|
||||||
|
shift 3
|
||||||
|
if ssh_directive_needs_change "$key" "$value"; then
|
||||||
|
pending_keys+=("$key")
|
||||||
|
pending_vals+=("$value")
|
||||||
|
pending_explanations+=("$explanation")
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local count="${#pending_keys[@]}"
|
||||||
|
|
||||||
|
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
|
||||||
|
for ((i = 0; i < count; i++)); do
|
||||||
|
printf ' %-45s %s\n' "${pending_keys[$i]} ${pending_vals[$i]}" "# ${pending_explanations[$i]}" >&2
|
||||||
|
done
|
||||||
|
printf '\n' >&2
|
||||||
|
|
||||||
|
if prompt_yn "Apply these ${count} directives?"; then
|
||||||
|
for ((i = 0; i < count; i++)); do
|
||||||
|
apply_single_ssh_directive "${pending_keys[$i]}" "${pending_vals[$i]}"
|
||||||
|
done
|
||||||
|
print_info "Applied ${count} SSH directives"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1612,11 +1701,28 @@ apply_ssh_config() {
|
|||||||
print_info "Created $SSH_CONFIG with mode 600"
|
print_info "Created $SSH_CONFIG with mode 600"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
apply_ssh_directive "StrictHostKeyChecking" "accept-new"
|
apply_ssh_directive_group "Host Verification" \
|
||||||
apply_ssh_directive "HashKnownHosts" "yes"
|
"Trust-on-first-use (TOFU): accept new host keys automatically, but reject
|
||||||
apply_ssh_directive "IdentitiesOnly" "yes"
|
changed keys (the actual MITM scenario). The default 'ask' just trains users
|
||||||
apply_ssh_directive "AddKeysToAgent" "yes"
|
to blindly type 'yes'. Hashing known_hosts prevents hostname enumeration if
|
||||||
apply_ssh_directive "PubkeyAcceptedAlgorithms" "ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com"
|
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)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -497,7 +497,7 @@ SSHEOF
|
|||||||
source_functions
|
source_functions
|
||||||
AUTO_YES=true
|
AUTO_YES=true
|
||||||
|
|
||||||
apply_ssh_directive "StrictHostKeyChecking" "accept-new"
|
apply_single_ssh_directive "StrictHostKeyChecking" "accept-new"
|
||||||
|
|
||||||
# Should still have exactly one occurrence
|
# Should still have exactly one occurrence
|
||||||
local count
|
local count
|
||||||
@@ -515,7 +515,7 @@ SSHEOF
|
|||||||
source_functions
|
source_functions
|
||||||
AUTO_YES=true
|
AUTO_YES=true
|
||||||
|
|
||||||
apply_ssh_directive "StrictHostKeyChecking" "accept-new"
|
apply_single_ssh_directive "StrictHostKeyChecking" "accept-new"
|
||||||
|
|
||||||
# Verify updated
|
# Verify updated
|
||||||
grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config"
|
grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config"
|
||||||
@@ -548,7 +548,7 @@ SSHEOF
|
|||||||
source_functions
|
source_functions
|
||||||
AUTO_YES=true
|
AUTO_YES=true
|
||||||
|
|
||||||
apply_ssh_directive "StrictHostKeyChecking" "accept-new"
|
apply_single_ssh_directive "StrictHostKeyChecking" "accept-new"
|
||||||
|
|
||||||
# Should still have exactly one occurrence
|
# Should still have exactly one occurrence
|
||||||
local count
|
local count
|
||||||
@@ -1156,7 +1156,7 @@ EOF
|
|||||||
# v0.2.0: Version bump
|
# v0.2.0: Version bump
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
@test "--version reports 0.2.3" {
|
@test "--version reports 0.4.0" {
|
||||||
run bash "$SCRIPT" --version
|
run bash "$SCRIPT" --version
|
||||||
assert_output --partial "0.2.3"
|
assert_output --partial "0.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user