diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5eec8f7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "test/libs/bats-core"] + path = test/libs/bats-core + url = https://github.com/bats-core/bats-core.git +[submodule "test/libs/bats-support"] + path = test/libs/bats-support + url = https://github.com/bats-core/bats-support.git +[submodule "test/libs/bats-assert"] + path = test/libs/bats-assert + url = https://github.com/bats-core/bats-assert.git diff --git a/test/git-harden.bats b/test/git-harden.bats new file mode 100755 index 0000000..c958205 --- /dev/null +++ b/test/git-harden.bats @@ -0,0 +1,688 @@ +#!/usr/bin/env bats + +# git-harden.sh — BATS test suite +# Runs in an isolated HOME to avoid touching real config. + +BATS_TEST_DIRNAME="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" +SCRIPT="${BATS_TEST_DIRNAME}/../git-harden.sh" + +load 'libs/bats-support/load' +load 'libs/bats-assert/load' + +# --------------------------------------------------------------------------- +# Test isolation: every test gets its own HOME, GIT_CONFIG, SSH_DIR +# --------------------------------------------------------------------------- + +setup() { + TEST_HOME="$(mktemp -d)" + export HOME="$TEST_HOME" + export GIT_CONFIG_GLOBAL="${TEST_HOME}/.gitconfig" + + mkdir -p "${TEST_HOME}/.ssh" + mkdir -p "${TEST_HOME}/.config/git" + + # Ensure git has user.name/email so config operations work + git config --global user.name "Test User" + git config --global user.email "test@example.com" +} + +teardown() { + rm -rf "$TEST_HOME" +} + +# Helper: source the script's functions without running main() +# We replace main() with a no-op so we can call functions individually. +source_functions() { + # Disable errexit so we can test error paths + set +o errexit + # Override main and readonly to allow re-sourcing + eval "$(sed 's/^main "\$@"$//' "$SCRIPT" | sed 's/^readonly //' | sed '/^set -o errexit/d; /^set -o nounset/d; /^set -o pipefail/d; /^IFS=/d')" + set -o errexit +} + +# =========================================================================== +# Argument parsing +# =========================================================================== + +@test "--help prints usage and exits 0" { + run bash "$SCRIPT" --help + assert_success + assert_output --partial "Usage: git-harden.sh" +} + +@test "-h prints usage and exits 0" { + run bash "$SCRIPT" -h + assert_success + assert_output --partial "Usage: git-harden.sh" +} + +@test "--version prints version and exits 0" { + run bash "$SCRIPT" --version + assert_success + assert_output --partial "git-harden.sh" +} + +@test "unknown option exits 1" { + run bash "$SCRIPT" --bogus + assert_failure + assert_output --partial "Unknown option" +} + +# =========================================================================== +# Version comparison (version_gte) +# =========================================================================== + +@test "version_gte: equal versions" { + source_functions + run version_gte "2.34.0" "2.34.0" + assert_success +} + +@test "version_gte: higher major" { + source_functions + run version_gte "3.0.0" "2.34.0" + assert_success +} + +@test "version_gte: higher minor" { + source_functions + run version_gte "2.40.0" "2.34.0" + assert_success +} + +@test "version_gte: higher patch" { + source_functions + run version_gte "2.34.1" "2.34.0" + assert_success +} + +@test "version_gte: lower version fails" { + source_functions + run version_gte "2.33.9" "2.34.0" + assert_failure +} + +@test "version_gte: lower minor fails" { + source_functions + run version_gte "2.20.0" "2.34.0" + assert_failure +} + +# =========================================================================== +# Audit: git config settings +# =========================================================================== + +@test "audit reports MISS for unconfigured setting" { + source_functions + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_git_setting "transfer.fsckObjects" "true" + assert_output --partial "[MISS]" +} + +@test "audit reports OK for correctly configured setting" { + git config --global transfer.fsckObjects true + + source_functions + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_git_setting "transfer.fsckObjects" "true" + assert_output --partial "[OK]" +} + +@test "audit reports WARN for wrong value" { + git config --global transfer.fsckObjects false + + source_functions + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_git_setting "transfer.fsckObjects" "true" + assert_output --partial "[WARN]" +} + +# =========================================================================== +# Audit: credential helper +# =========================================================================== + +@test "audit warns on credential.helper=store" { + git config --global credential.helper store + + source_functions + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_git_config + assert_output --partial "INSECURE" + assert_output --partial "plaintext" +} + +# =========================================================================== +# Audit: pull.rebase conflict warning +# =========================================================================== + +@test "audit warns when pull.rebase conflicts with pull.ff=only" { + git config --global pull.rebase true + + source_functions + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_git_config + assert_output --partial "pull.rebase" + assert_output --partial "conflicts" +} + +# =========================================================================== +# Audit: signing +# =========================================================================== + +@test "audit reports MISS when no signing key configured" { + source_functions + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_signing + assert_output --partial "[MISS]" + assert_output --partial "user.signingkey" +} + +@test "audit reports OK for valid signing key file" { + # Create a fake key file + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q + git config --global user.signingkey "${TEST_HOME}/.ssh/id_ed25519.pub" + git config --global gpg.format ssh + git config --global gpg.ssh.allowedSignersFile "~/.config/git/allowed_signers" + git config --global commit.gpgsign true + git config --global tag.gpgsign true + git config --global tag.forceSignAnnotated true + + source_functions + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_signing + assert_output --partial "[OK]" + refute_output --partial "[MISS]" +} + +@test "audit handles inline SSH key" { + git config --global user.signingkey "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFake" + + source_functions + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_signing + assert_output --partial "inline key" +} + +@test "audit warns for signing key pointing to missing file" { + git config --global user.signingkey "/nonexistent/key.pub" + + source_functions + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_signing + assert_output --partial "[WARN]" + assert_output --partial "file not found" +} + +# =========================================================================== +# Audit: SSH config +# =========================================================================== + +@test "audit reports MISS when SSH config missing" { + rm -f "${TEST_HOME}/.ssh/config" + + source_functions + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_ssh_config + assert_output --partial "[MISS]" + assert_output --partial "does not exist" +} + +@test "audit reports OK for correct SSH directives" { + cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF' +StrictHostKeyChecking accept-new +HashKnownHosts yes +IdentitiesOnly yes +AddKeysToAgent yes +PubkeyAcceptedAlgorithms ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com +SSHEOF + + source_functions + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_ssh_config + # Should have 5 OK and no MISS + refute_output --partial "[MISS]" + refute_output --partial "[WARN]" +} + +@test "audit reports WARN for wrong SSH directive value" { + cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF' +StrictHostKeyChecking yes +SSHEOF + + source_functions + AUDIT_OK=0; AUDIT_WARN=0; AUDIT_MISS=0 + + run audit_ssh_directive "StrictHostKeyChecking" "accept-new" + assert_output --partial "[WARN]" +} + +# =========================================================================== +# Audit report & exit codes +# =========================================================================== + +@test "audit report returns 0 when all OK" { + source_functions + AUDIT_OK=5; AUDIT_WARN=0; AUDIT_MISS=0 + + run print_audit_report + assert_success + assert_output --partial "5 OK" +} + +@test "audit report returns 2 when issues found" { + source_functions + AUDIT_OK=3; AUDIT_WARN=1; AUDIT_MISS=2 + + run print_audit_report + assert_failure 2 + assert_output --partial "1 WARN" + assert_output --partial "2 MISS" +} + +# =========================================================================== +# Apply: git config settings (-y mode) +# =========================================================================== + +@test "-y mode applies git config settings" { + source_functions + AUTO_YES=true + + run apply_git_setting "transfer.fsckObjects" "true" + assert_success + + local result + result="$(git config --global --get transfer.fsckObjects)" + [ "$result" = "true" ] +} + +@test "apply skips already-correct setting" { + git config --global transfer.fsckObjects true + + source_functions + AUTO_YES=true + + run apply_git_setting "transfer.fsckObjects" "true" + assert_success + # Should produce no output (no "Set" message) + refute_output --partial "Set" +} + +# =========================================================================== +# Apply: full git config (-y mode, end-to-end) +# =========================================================================== + +@test "-y mode applies all hardening settings" { + source_functions + AUTO_YES=true + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + + run apply_git_config + assert_success + + # Verify a sampling of the applied settings + [ "$(git config --global transfer.fsckObjects)" = "true" ] + [ "$(git config --global protocol.allow)" = "never" ] + [ "$(git config --global protocol.https.allow)" = "always" ] + [ "$(git config --global protocol.ext.allow)" = "never" ] + [ "$(git config --global core.protectNTFS)" = "true" ] + [ "$(git config --global core.protectHFS)" = "true" ] + [ "$(git config --global core.fsmonitor)" = "false" ] + [ "$(git config --global safe.bareRepository)" = "explicit" ] + [ "$(git config --global submodule.recurse)" = "false" ] + [ "$(git config --global pull.ff)" = "only" ] + [ "$(git config --global merge.ff)" = "only" ] + [ "$(git config --global http.sslVerify)" = "true" ] + [ "$(git config --global log.showSignature)" = "true" ] + [ "$(git config --global credential.helper)" = "osxkeychain" ] +} + +@test "-y mode applies url.https rewrite" { + source_functions + AUTO_YES=true + PLATFORM="macos" + DETECTED_CRED_HELPER="osxkeychain" + + apply_git_config + + local result + result="$(git config --global --get 'url.https://.insteadOf')" + [ "$result" = "http://" ] +} + +# =========================================================================== +# Apply: SSH config +# =========================================================================== + +@test "apply creates SSH dir and config with correct permissions" { + rm -rf "${TEST_HOME}/.ssh" + + source_functions + AUTO_YES=true + + run apply_ssh_config + assert_success + + # Check directory exists with correct mode + [ -d "${TEST_HOME}/.ssh" ] + [ -f "${TEST_HOME}/.ssh/config" ] + + local dir_perms + dir_perms="$(stat -f '%Lp' "${TEST_HOME}/.ssh" 2>/dev/null || stat -c '%a' "${TEST_HOME}/.ssh" 2>/dev/null)" + [ "$dir_perms" = "700" ] + + local file_perms + file_perms="$(stat -f '%Lp' "${TEST_HOME}/.ssh/config" 2>/dev/null || stat -c '%a' "${TEST_HOME}/.ssh/config" 2>/dev/null)" + [ "$file_perms" = "600" ] +} + +@test "apply adds SSH directives to empty config" { + : > "${TEST_HOME}/.ssh/config" + + source_functions + AUTO_YES=true + + run apply_ssh_config + assert_success + + # Verify directives were added + grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config" + grep -q "HashKnownHosts yes" "${TEST_HOME}/.ssh/config" + grep -q "IdentitiesOnly yes" "${TEST_HOME}/.ssh/config" + grep -q "AddKeysToAgent yes" "${TEST_HOME}/.ssh/config" +} + +@test "apply skips SSH directives that already exist with correct value" { + cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF' +StrictHostKeyChecking accept-new +SSHEOF + + source_functions + AUTO_YES=true + + apply_ssh_directive "StrictHostKeyChecking" "accept-new" + + # Should still have exactly one occurrence + local count + count="$(grep -c "StrictHostKeyChecking" "${TEST_HOME}/.ssh/config")" + [ "$count" -eq 1 ] +} + +@test "apply updates SSH directive with wrong value" { + cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF' +Host * + StrictHostKeyChecking yes + HashKnownHosts no +SSHEOF + + source_functions + AUTO_YES=true + + apply_ssh_directive "StrictHostKeyChecking" "accept-new" + + # Verify updated + grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config" + # Old value should be gone + ! grep -q "StrictHostKeyChecking yes" "${TEST_HOME}/.ssh/config" + # Other directives should be preserved + grep -q "HashKnownHosts no" "${TEST_HOME}/.ssh/config" +} + +# =========================================================================== +# Signing: key detection +# =========================================================================== + +@test "detect_existing_keys finds ed25519 key" { + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q + + source_functions + detect_existing_keys + + [ "$SIGNING_KEY_FOUND" = true ] + [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519.pub" ] +} + +@test "detect_existing_keys prefers sk key over software key" { + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q + # Fake an sk key (can't generate real one without hardware) + cp "${TEST_HOME}/.ssh/id_ed25519" "${TEST_HOME}/.ssh/id_ed25519_sk" + # Write a fake pub key with sk type prefix + printf 'sk-ssh-ed25519@openssh.com AAAAFakeKey test\n' > "${TEST_HOME}/.ssh/id_ed25519_sk.pub" + + source_functions + detect_existing_keys + + [ "$SIGNING_KEY_FOUND" = true ] + [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_sk.pub" ] +} + +@test "detect_existing_keys finds key from IdentityFile directive" { + # Create a key with a non-standard name + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/my_custom_key" -N "" -q + + cat > "${TEST_HOME}/.ssh/config" </dev/null | head -1)" + [ -n "$backup_file" ] + grep -q "transfer.fsckobjects=true" "$backup_file" +} + +# =========================================================================== +# End-to-end: --audit mode +# =========================================================================== + +@test "--audit exits 2 on fresh config" { + run bash "$SCRIPT" --audit + assert_failure 2 + assert_output --partial "MISS" +} + +@test "--audit exits 0 when fully hardened" { + # Apply all settings first + bash "$SCRIPT" -y 2>/dev/null + + run bash "$SCRIPT" --audit + # May still exit 2 if SSH config or signing isn't fully set up, + # but git config settings should be OK + assert_output --partial "[OK]" +} + +# =========================================================================== +# End-to-end: -y mode +# =========================================================================== + +@test "-y mode runs without prompts and applies config" { + run bash "$SCRIPT" -y + assert_success + assert_output --partial "Hardening complete" + + # Spot-check a few settings + [ "$(git config --global transfer.fsckObjects)" = "true" ] + [ "$(git config --global protocol.allow)" = "never" ] + [ "$(git config --global pull.ff)" = "only" ] +} + +@test "-y mode is idempotent" { + bash "$SCRIPT" -y 2>/dev/null + + run bash "$SCRIPT" -y + assert_success + # Should still succeed on second run + assert_output --partial "Hardening complete" +} + +# =========================================================================== +# Platform detection +# =========================================================================== + +@test "detect_platform sets PLATFORM" { + source_functions + detect_platform + + # We're running on macOS or Linux + [[ "$PLATFORM" = "macos" || "$PLATFORM" = "linux" ]] +} + +# =========================================================================== +# Admin recommendations (smoke test) +# =========================================================================== + +@test "admin recommendations print without error" { + source_functions + run print_admin_recommendations + assert_success + assert_output --partial "branch protection" + assert_output --partial "vigilant mode" +} diff --git a/test/libs/bats-assert b/test/libs/bats-assert new file mode 160000 index 0000000..697471b --- /dev/null +++ b/test/libs/bats-assert @@ -0,0 +1 @@ +Subproject commit 697471b7a89d3ab38571f38c6c7c4b460d1f5e35 diff --git a/test/libs/bats-core b/test/libs/bats-core new file mode 160000 index 0000000..d9faff0 --- /dev/null +++ b/test/libs/bats-core @@ -0,0 +1 @@ +Subproject commit d9faff0d7bc32e7adebc6552446f978118d3ab3b diff --git a/test/libs/bats-support b/test/libs/bats-support new file mode 160000 index 0000000..0954abb --- /dev/null +++ b/test/libs/bats-support @@ -0,0 +1 @@ +Subproject commit 0954abb9925cad550424cebca2b99255d4eabe96 diff --git a/test/run.sh b/test/run.sh new file mode 100755 index 0000000..11cba79 --- /dev/null +++ b/test/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Run the BATS test suite +set -o errexit +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +"${SCRIPT_DIR}/libs/bats-core/bin/bats" "${SCRIPT_DIR}/git-harden.bats" "$@"