diff --git a/CHANGELOG.md b/CHANGELOG.md index adb0952..363ecb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Signing wizard "Skip" option now clarifies the agent-container use case (humans sign at PR merge) +## [0.6.0] - 2026-06-09 + +Fixes for the critical and high findings of an external security review, plus audit tiering. + +### Security / Critical fixes +- `--reset-signing` no longer lists general-purpose keys (`id_ed25519`, `id_ed25519_sk`, `id_ecdsa_sk`) for deletion — they are likely SSH **authentication** keys. Only dedicated `*_signing` keys (and a configured key matching that convention) are candidates +- `--reset-signing` deletion prompt now defaults to **No**, offers delete / rename-to-.bak / leave-untouched, and **never deletes files in `-y` mode** +- FIDO2 key generation: "insert key and retry" actually retries the same attempt (a `for`-loop reassignment bug silently advanced to the next fallback type instead) +- `core.hooksPath` redirection no longer silently disables repo-local hooks: dispatch stubs are installed for all client-side hook types, forwarding to `.git/hooks/` when present. The pre-commit hook combines the gitleaks scan with dispatch, and existing pre-dispatch hooks are offered an upgrade +- Pre-commit hook now **warns loudly** when gitleaks is missing instead of silently skipping the secret scan (still fail-open by design) +- Private key material can no longer end up in `allowed_signers` or `user.signingkey`: public-key validation everywhere a key path is consumed; a `user.signingkey` pointing at a private key is recovered to its `.pub` neighbor +- Config backups (`pre-harden-backup-*.txt`) are created with mode 600 — the gitconfig dump can contain tokens (`http.extraHeader`, `insteadOf` URLs) + +### SSH config handling (high fixes) +- Audit and apply are now **scope-aware**: only directives in global scope (top-level or `Host *`) count; a directive set only in a host-specific block is reported as "no global default" instead of a false OK +- Replacements only touch global-scope occurrences and preserve indentation; host-specific values are never rewritten +- New directives are appended in a `Host *` block at **EOF** (previously prepended/inserted at top, which shadowed host-specific blocks under ssh's first-obtained-wins rule) +- `Include`-d config files are scanned for `IdentityFile` keys (one level); audit prints a notice that included directives are not audited/modified +- Pubkey algorithm restriction is guarded: skipped with a warning when the only available keys (on disk or in the SSH agent) are RSA/DSA (lockout risk; default-No override, never applied in `-y`), and the directive name is chosen per OpenSSH version (`PubkeyAcceptedKeyTypes` before 8.5; skipped for unknown/ancient clients where it would break every ssh invocation) + +### Added +- Audit tiers: every item is classified security / hygiene / preference; the summary reports per-tier counts and `--audit` exit code 2 now reflects **security-tier issues only** +- Signing smoke test after enabling signing: signs a test message and verifies it against `allowed_signers` with the recorded principal, catching "No principal matched" at setup time +- `http.sslVerify` audit distinguishes unset (OK — git default is true) from an explicit insecure override + +### Tests +- 14 new BATS tests (126 total): reset-signing safety, private-key guards, dispatch stubs, pre-commit dispatch and gitleaks warning, SSH scope handling, audit tiers, version bump + ## [0.5.0] - 2026-04-05 ### Added diff --git a/docs/REASONING.md b/docs/REASONING.md index 77bdd87..b76df64 100644 --- a/docs/REASONING.md +++ b/docs/REASONING.md @@ -4,6 +4,16 @@ Every setting `git-harden.sh` audits or applies exists because of a specific att Settings are grouped the same way they appear in the script's audit output. +## Audit Tiers + +Not every audited item carries the same weight, so each one belongs to one of three tiers: + +- **security** — protects against a concrete attack vector (protocol restrictions, object integrity, hook control, credential storage, signing, …) +- **hygiene** — operational robustness and forensic readiness (reflog retention, `fetch.prune`, `user.useConfigOnly`, `pull.ff`, …) +- **preference** — ecosystem alignment with no security impact (`init.defaultBranch`, `log.showSignature`) + +The audit summary reports issue counts per tier. In `--audit` mode, **only security-tier issues produce exit code 2** — hygiene and preference findings are reported but never fail a CI or fleet-compliance check. This keeps the exit code meaningful as a gate: a machine that fails the audit has an actual attack-surface problem, not a branch-naming opinion. + --- ## Identity @@ -28,9 +38,19 @@ Settings are grouped the same way they appear in the script's audit output. **Attack/risk mitigated:** Malicious or corrupted packfiles that exploit parsing vulnerabilities in the git binary. Historical CVEs include integer overflows in packfile handling and crafted objects that trigger code execution. Also catches silent data corruption from disk/network errors. -**What could break:** Adds ~5-10% overhead to clone and fetch operations on large repositories. Some very old repositories with technically malformed (but benign) objects may fail to clone until the upstream runs `git fsck --full` and fixes them. +**What could break:** Adds ~5-10% overhead to clone and fetch operations on large repositories. More importantly: a meaningful number of popular, actively maintained repositories contain historic objects that are technically malformed but benign (zero-padded file modes, missing tagger lines, malformed committer dates) — cloning those will **fail**, not just warn. This is not a rare-edge-case setting. -**Why this default:** The performance cost is small. The alternative — silently accepting corrupted objects — has no upside. +**Mitigation when it bites:** Don't turn fsck back off globally. Downgrade the specific, known-benign message class instead, e.g.: + +``` +git config --global fetch.fsck.zeroPaddedFilemode ignore +git config --global fetch.fsck.badTimezone ignore +git config --global fetch.fsck.missingTaggerEntry ignore +``` + +This keeps structural/hash validation intact while tolerating the legacy quirk you actually hit. + +**Why this default:** The performance cost is small and the failure mode is loud and fixable per message class. The alternative — silently accepting corrupted or malicious objects — has no upside. ### `transfer.bundleURI = false` @@ -48,9 +68,9 @@ Settings are grouped the same way they appear in the script's audit output. **Attack/risk mitigated:** Stale remote refs can be confusing and misleading. In a security context, a deleted branch that still appears locally may cause a developer to base work on abandoned or reverted code. -**What could break:** Nothing. This matches the behavior of `git fetch --prune`. Pruning only affects remote-tracking refs, not local branches. +**What could break:** Pruning only affects remote-tracking refs, not local branches. One caveat: when a remote-tracking ref was the *only* reference to some commits, pruning makes them unreachable and therefore eligible for garbage collection once the reflog entries expire. The extended reflog retention configured under Forensic Readiness keeps that window long (90+ days), but "zero downside" would overstate it. -**Why this default:** Pure hygiene with zero downside. +**Why this default:** Hygiene with a negligible, reflog-mitigated downside. Tier: hygiene. --- @@ -101,11 +121,11 @@ Settings are grouped the same way they appear in the script's audit output. **What it does:** Disables the filesystem monitor integration (fsmonitor, Watchman). This feature speeds up `git status` in large repos by using OS-level file change notifications. -**Attack/risk mitigated:** The fsmonitor hook (`core.fsmonitor--hook-version`) can execute arbitrary commands. A malicious repository could set this in its local config. Disabling it globally prevents this vector. +**Attack/risk mitigated:** `core.fsmonitor` can point at an arbitrary command, which git then executes. **An important precision about what this setting does and does not protect against:** git config precedence means a *local* (`.git/config`) value overrides the global one — so setting `false` globally does **not** neutralize a repository that carries its own fsmonitor setting. What actually limits that attack is that `.git/config` is not transferred on clone; the realistic delivery vector is an embedded/planted `.git` directory, which is what `safe.bareRepository` and git's ownership checks (CVE-2022-24765 fix) address. The global `false` here is defense-in-depth: it prevents the feature from being silently enabled by tooling or copied configs, and it removes a code-execution-capable knob from the default environment. **What could break:** Performance of `git status` in very large repositories (100k+ files) where fsmonitor provides significant speedups. Developers working on such repos can override this per-repo. -**Why this default:** Most repositories are not large enough to notice the difference. The attack surface is not worth the performance gain for typical use. +**Why this default:** Most repositories are not large enough to notice the difference. The attack surface is not worth the performance gain for typical use — but be aware it is a hardening layer, not the primary defense. ### `core.symlinks = false` (interactive-only, skipped in `-y` mode) @@ -125,11 +145,13 @@ Settings are grouped the same way they appear in the script's audit output. **What it does:** Redirects git hook execution from each repository's `.git/hooks/` directory to a centralized, user-controlled directory. -**Attack/risk mitigated:** Malicious repositories can include hooks (e.g., `pre-commit`, `post-checkout`) that execute on clone, commit, or checkout. By redirecting to a user-managed directory, repo-local hooks are ignored unless explicitly installed. +**Attack/risk mitigated:** Malicious repositories can include hooks (e.g., `pre-commit`, `post-checkout`) that execute on clone, commit, or checkout. By redirecting to a user-managed directory, repo-local hooks are ignored unless explicitly dispatched. -**What could break:** Project-specific hooks defined in `.git/hooks/` or installed by frameworks like `husky`, `lefthook`, or `pre-commit`. Teams using these must either: (a) install their hooks into the global hooks directory, or (b) override `core.hooksPath` per-repo via `git config --local`. +**How repo-local hooks keep working:** Redirecting hooks would otherwise *silently* disable every repo-local hook of every type — including security hooks teams installed deliberately (husky, lefthook, the `pre-commit` framework). To prevent that, the script installs **dispatch stubs** for all client-side hook types in the global hooks directory. Each stub forwards to the repository's own `.git/hooks/` when present and executable. The `pre-commit` stub additionally runs the gitleaks secret scan first. The result: you get centralized control plus a visible choke point, without breaking per-repo workflows. (Repos that set `core.hooksPath` in their local config — husky v9 does — override the global value entirely and are unaffected.) -**Why this default:** The attack is trivial to execute and devastating (arbitrary code execution). Teams that need repo-local hooks can override per-repo. +**What could break:** Hooks installed into `.git/hooks/` still run, but now *after* the global stub's own logic (for `pre-commit`: after the secret scan). Tools that verify their hook is "installed" by checking the effective `core.hooksPath` may complain. + +**Why this default:** The attack is trivial to execute and devastating (arbitrary code execution). With dispatch stubs the usual breakage objection no longer applies. --- @@ -143,7 +165,9 @@ Settings are grouped the same way they appear in the script's audit output. **What could break:** False positives on test fixtures or example credentials may require bypassing with `SKIP_GITLEAKS=1 git commit`. Adds ~1-2 seconds to each commit. -**Why this default:** Both research reports rank pre-commit secret scanning as the #1 workstation-level defense. The hook is safe without gitleaks installed (guards with `command -v`). The `SKIP_GITLEAKS` bypass avoids the need for `--no-verify` which skips ALL hooks. +**Fail-open by design, but loudly:** If gitleaks is not installed, the hook does not block commits — but it prints a clearly visible "secret scan SKIPPED" warning on every commit instead of silently doing nothing. Failing *closed* (blocking all commits until gitleaks is installed) was considered and rejected: it punishes machines the user doesn't fully control and trains people to delete the hook. A loud warning preserves the signal without the lockout. + +**Why this default:** Both research reports rank pre-commit secret scanning as the #1 workstation-level defense. The `SKIP_GITLEAKS` bypass avoids the need for `--no-verify` which skips ALL hooks. After scanning, the hook dispatches to the repository's own `pre-commit` hook (see Hook Control above). --- @@ -213,6 +237,8 @@ Settings are grouped the same way they appear in the script's audit output. **Attack/risk mitigated:** Disabling SSL verification allows MITM attacks even over HTTPS — the attacker presents any certificate and git accepts it. +**Audit semantics:** Unset is **not** the same as overridden. The audit reports an unset `http.sslVerify` as OK (git's built-in default is `true`) and only flags an explicit insecure override. The apply phase still pins it to `true` when other changes are being made, as a guard against future overrides in lower-precedence scopes. + **What could break:** Self-signed certificates on internal git servers. The proper fix is to add the CA certificate to git's trust store (`http.sslCAInfo`), not to disable verification globally. **Why this default:** Ensuring the default hasn't been overridden. This is a safety net, not a new restriction. @@ -297,7 +323,13 @@ Settings are grouped the same way they appear in the script's audit output. **Attack/risk mitigated:** Makes unsigned or invalid signatures visible in normal workflow. Without this, developers must remember to use `git log --show-signature` to check. -**What could break:** Log output is slightly more verbose. Some terminal environments may not render the verification status cleanly. +**What could break:** More than "slightly more verbose" — be honest about this one: + +- **Anything that parses `git log` output breaks.** Scripts, release tooling, and integrations that read log output without `--no-show-signature` get signature blocks interleaved with the text they expect to parse. +- **Every log invocation pays a verification cost** (an `ssh-keygen`/`gpg` call per displayed commit), which is noticeable on large histories. +- **Without a matching allowed_signers entry, every entry shows "No principal matched"** noise — which is why the script now smoke-tests the signature round-trip at setup time. + +Scripts you control should use `git log --no-show-signature` or `git -c log.showSignature=false`. Tier: preference — turn it off if it fights your tooling; verification on the hosting platform is unaffected. **Why this default:** Signature verification is only useful if people see the results. Making it visible by default closes the gap between "we sign commits" and "we verify signatures." @@ -331,10 +363,14 @@ Settings are grouped the same way they appear in the script's audit output. **What could break:** Nothing — the file is additive. Without it, local verification simply doesn't work (signatures are only verified on the hosting platform). +**The "No principal matched" trap:** verification matches the *committer email* against the principals in this file. If a repo overrides `user.email` (work vs. personal identities) or the entry was written with a different address, `git log` shows "Good signature … No principal matched" on every commit. To catch this at setup time instead of in every future log, the script offers a signing smoke test after enabling signing: it signs a test message with the configured key and verifies it against allowed_signers with the recorded principal. Per-identity setups need one allowed_signers line per email (the same key can appear on multiple lines). + --- ## SSH Configuration +**How the script reads and writes `~/.ssh/config`:** ssh resolves options with *first-obtained-wins* semantics, and a directive inside a `Host github.com` block does not apply globally. The audit therefore only counts directives in **global scope** (top-level lines or a `Host *` block) — a directive that exists only in host-specific blocks is reported as "no global default". When applying, the script replaces values in global scope only, and appends new directives in a `Host *` block at the **end** of the file, so existing host-specific settings always keep precedence. `Include`-d files are scanned for keys (one level deep) but their directives are not audited or modified; the audit prints a notice when Includes are present. + ### `StrictHostKeyChecking = accept-new` **What it does:** Automatically accepts host keys on first connection (TOFU — Trust On First Use) but rejects changed keys on subsequent connections. @@ -371,6 +407,11 @@ Settings are grouped the same way they appear in the script's audit output. **What could break:** Connections to legacy servers that only support RSA. These servers should be upgraded; RSA-SHA1 is deprecated by OpenSSH since version 8.7. +**Guards the script applies before setting this:** + +1. **Key inventory.** If the only available keys (on disk *or* loaded in the SSH agent) are RSA/DSA, applying this directive would lock the user out of every server those keys authenticate to. The script warns, requires an explicit default-No confirmation, and skips entirely in `-y` mode. +2. **OpenSSH version.** The option is spelled `PubkeyAcceptedAlgorithms` from OpenSSH 8.5 and `PubkeyAcceptedKeyTypes` before that — and an unknown option in `~/.ssh/config` makes *every* ssh invocation fail with "Bad configuration option". The script detects the client version and writes the correct spelling, or skips the directive when the client is too old or not OpenSSH. + **Why these algorithms:** Ed25519 is the recommended default (fast, small keys, no parameter pitfalls). ECDSA P-256 is included because some FIDO2 hardware keys only support it. RSA is excluded because accepting it creates a fallback path to weaker cryptography. --- diff --git a/docs/specs/2026-06-09-agent-backed-keys.md b/docs/specs/2026-06-09-agent-backed-keys.md new file mode 100644 index 0000000..7aea16c --- /dev/null +++ b/docs/specs/2026-06-09-agent-backed-keys.md @@ -0,0 +1,112 @@ +# git-harden.sh — Agent-Backed Keys: 1Password / Bitwarden / Forwarded Agents, Zero Plaintext Keys on Disk + +Status: **proposed** (plan for v0.7.0) +Depends on: v0.6.0 (scope-aware SSH config handling, key inventory via `ssh-add -L`, signing smoke test) + +## Motivation + +The script currently assumes file-based keys: it scans `~/.ssh/*.pub`, generates keys to disk, and treats "no key files" as "no keys". That assumption breaks — and in some places actively fights — the strongest available key-storage model: + +- **1Password / Bitwarden SSH agents** hold private keys encrypted in a vault and expose them only through an agent socket, with per-use approval prompts. No private key ever touches disk. +- **Hardware keys** (FIDO2 resident keys) keep the private key in the authenticator. +- **Forwarded agents** (`SSH_AUTH_SOCK` in a remote/container session) let agent-less environments — including agent containers used for AI-assisted development — sign and authenticate without holding any identity locally. + +The end state this plan targets: **a machine can pass the full audit and have working signing + authentication with zero plaintext private keys on disk.** Today it can't: the audit reports "No SSH public keys found", the signing wizard only offers to generate file-based keys, and `IdentitiesOnly yes` (which we set) can silently break agent-only setups. + +## Current gaps + +| # | Gap | Where | +|---|-----|-------| +| 1 | Key hygiene audit ignores agent-held keys ("No SSH public keys found" on a fully provisioned 1Password machine) | `audit_ssh_key_hygiene` | +| 2 | Signing wizard offers only on-disk generation (software file or FIDO2 file handle) | `signing_wizard` | +| 3 | `IdentitiesOnly yes` is applied without checking that configured `IdentityFile` stubs exist — agent-only keys stop being offered | `apply_ssh_config` | +| 4 | No detection of *unencrypted* private keys on disk — the exact artifact this plan wants to eliminate | missing audit | +| 5 | No `ForwardAgent` policy; forwarded-agent risk/benefit is undocumented | missing audit + docs | +| 6 | Signing smoke test requires a private key file next to the `.pub` | `verify_signing_setup` | + +## Design + +### Phase 1 — Detection & audit (no behavior changes) + +**1a. Agent detection helper.** `detect_ssh_agents` reports every reachable agent: + +- `SSH_AUTH_SOCK` (generic; also covers forwarded agents — detect forwarding via `SSH_CONNECTION`/`SSH_TTY` being set alongside it) +- 1Password: `~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock` (macOS), `~/.1password/agent.sock` (Linux) +- Bitwarden: `~/.bitwarden-ssh-agent.sock` +- gpg-agent: `gpgconf --list-dirs agent-ssh-socket` + +Probe each socket with `SSH_AUTH_SOCK= ssh-add -L`; report agent type, reachability, and key count. Sockets are *probed read-only*; nothing is written. + +**1b. Agent keys join the key-hygiene audit.** `audit_ssh_key_hygiene` merges `ssh-add -L` output (per detected agent) with on-disk `.pub` files, deduplicated by key blob. Agent keys get the same weak-algorithm checks and are labeled `(agent: 1password)` etc. "No SSH public keys found" only appears when disk *and* agents are empty. + +**1c. New audit: plaintext private keys on disk** (tier: security). + +- Enumerate candidate private keys in `~/.ssh` (files whose first line is an OpenSSH/PEM private key header — never by filename guessing). +- Classify encrypted vs. unencrypted: `ssh-keygen -y -P "" -f ` succeeding in batch mode ⇒ **unencrypted**. +- `[WARN security]` unencrypted private key on disk → recommend vault import + passphrase or deletion after migration. +- `[INFO]` encrypted private key on disk when an agent holds the same public key → candidate for cleanup (Phase 4). +- Audit-only, like the existing credential-hygiene section: never modify or delete. + +**1d. `ForwardAgent` audit** (tier: security). `[OK]` when unset/`no` globally; `[WARN]` when `yes` in global scope ("any root user on any host you ssh to can use your keys while connected — scope it per-host, prefer agents with per-use confirmation"). Uses the v0.6.0 scope-aware reader. + +### Phase 2 — Signing without key files + +**2a. Wizard option 3: "Use a key from your SSH agent".** Lists agent keys (`ssh-add -L`, modern algorithms only), lets the user pick one, then: + +- `user.signingkey = key:: ` (literal pub key, no file needed; supported since Git 2.34 for `gpg.format=ssh`) +- allowed_signers entry written from the same literal key (existing `setup_allowed_signers` path, fed by string instead of file — refactor it to accept key material, keeping the public-key validation guard) +- In `-y` mode with no file-based key found: if exactly one modern agent key exists, use it (consistent with the existing "found existing key" auto-pick); otherwise skip with the current message. + +**2b. Smoke test via agent.** `verify_signing_setup` learns a second path: when no private key file exists, sign with `ssh-keygen -Y sign -U -f ` (`-U`: private half lives in the agent). Same prompt gating — 1Password/Bitwarden will pop an approval dialog, which is exactly what we want the user to see once at setup time. + +**2c. Audit accepts `key::` signing keys.** `audit_signing` currently prints `(inline key)` for `ssh-*` values; extend the case to `key::*` and verify the *allowed_signers* file contains the same blob. + +### Phase 3 — SSH config integration + +**3a. `IdentitiesOnly` guard.** Before applying `IdentitiesOnly yes`, check: does any `IdentityFile` directive exist in global scope, or do on-disk `.pub` stubs match agent keys? If the user is agent-only with no stubs, offer to write **public-key stubs** (`~/.ssh/.pub` — public material only, consistent with the zero-plaintext goal) plus matching `IdentityFile` lines, so `IdentitiesOnly yes` keeps working with the agent. Decline ⇒ skip the directive with a warning instead of applying a lockout. + +**3b. `IdentityAgent` offering.** When a 1Password/Bitwarden socket is detected and `SSH_AUTH_SOCK` doesn't already point at it, offer a global `IdentityAgent ` directive (written with the v0.6.0 append-at-EOF semantics). Never overwrite an existing `IdentityAgent`. + +**3c. `ForwardAgent no` as an applied default** (matching the Phase 1d audit), with documentation of the per-host override pattern and a note that confirmation-prompting agents (1Password, `ssh-add -c`) are the safe way to use forwarding when it's unavoidable. + +### Phase 4 — Migration assistant (interactive only, opt-in) + +Goal: walk an existing file-based user to zero plaintext keys. Strictly guided, never automatic: + +1. Inventory on-disk private keys (from 1c) and show which are already represented in an agent. +2. Print vault-import instructions per detected agent (1Password and Bitwarden import flows differ; we print, we don't drive their CLIs). +3. After the user confirms a key is imported **and** the agent probe shows its public key, offer deletion of the on-disk private key under the v0.6.0 reset-signing safety rules: interactive only, default **No**, rename-to-`.bak` middle option, never in `-y` mode, keep the `.pub` stub for `IdentitiesOnly`. +4. Re-run the relevant audits to show the end state. + +### Documentation + +- New REASONING.md section "Agent-Backed Keys" covering: why vault agents beat files, forwarded-agent threat model, `key::` signing, pub-stub pattern for `IdentitiesOnly`. +- README quick-start for 1Password/Bitwarden users. + +## Acceptance criteria + +1. On a machine whose only keys live in a 1Password agent: full audit passes (given config applied), `audit_ssh_key_hygiene` lists the agent keys, signing works via `key::`, and the smoke test round-trips through the agent. +2. `git-harden.sh -y` never blocks on an agent approval prompt and never deletes/moves any key file. +3. Applying `IdentitiesOnly yes` is impossible in a state that would stop agent keys from being offered (stub-write or skip-with-warning). +4. An unencrypted private key in `~/.ssh` is flagged as a security-tier finding; an encrypted one is not flagged red. +5. Forwarded-agent sessions (`SSH_AUTH_SOCK` + `SSH_CONNECTION`) are detected and labeled; no file-based assumptions break inside such a session or an agent container. +6. All existing BATS tests keep passing; new tests use a throwaway `ssh-agent` (start, `ssh-add` a generated key, delete the private file, point `SSH_AUTH_SOCK` at it) so CI needs no vault products. + +## Test plan + +- **Unit (BATS):** agent detection with fake sockets; key inventory merge/dedup; unencrypted-key classifier (generated with/without `-N`); `key::` signingkey audit; `IdentitiesOnly` guard paths (stubs present / agent-only / decline). +- **Agent integration (BATS, real `ssh-agent`):** sign + verify via `-U` with the private key file removed — proves the zero-plaintext path end to end. +- **Container e2e:** extend `test/containers/*` with an `ssh-agent`-only variant (no `~/.ssh/id_*` private files) running `--audit` and `-y`. +- **Interactive:** new `test/interactive/test-signing-agent.sh` covering wizard option 3. + +## Non-goals + +- Driving 1Password/Bitwarden CLIs (`op`, `bws`) — instructions only; their CLIs change and require auth sessions. +- gpg-agent-backed *GPG* signing (this project standardizes on SSH signing). +- Hardware-token provisioning changes (FIDO2 path already exists and is orthogonal). + +## Open questions + +1. Should `-y` auto-adopt a single agent key for signing (2a) or stay fully passive? Current plan: adopt only when exactly one modern key exists, mirroring file-based behavior. Approval prompts make even this debatable in headless runs — the smoke test stays interactive-only either way. +2. Bitwarden's agent socket path is configurable; do we read its `data.json` to find it, or only check the default path and document the rest? Plan: default path + docs. +3. Stub naming for written `.pub` stubs (3a): derive from the key comment (sanitized) or `agent_.pub`? Plan: comment-derived with fingerprint fallback. diff --git a/git-harden.sh b/git-harden.sh index c98fb11..09c1f8a 100755 --- a/git-harden.sh +++ b/git-harden.sh @@ -10,7 +10,7 @@ IFS=$'\n\t' # ------------------------------------------------------------------------------ # Constants # ------------------------------------------------------------------------------ -readonly VERSION="0.5.0" +readonly VERSION="0.6.0" readonly BACKUP_DIR="${HOME}/.config/git" readonly HOOKS_DIR="${HOME}/.config/git/hooks" readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers" @@ -18,6 +18,18 @@ readonly GLOBAL_GITIGNORE="${HOME}/.config/git/ignore" readonly SSH_DIR="${HOME}/.ssh" readonly SSH_CONFIG="${SSH_DIR}/config" +readonly PUBKEY_ALGO_LIST="ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com" + +# Client-side hooks that get a dispatch stub when core.hooksPath is redirected, +# so repo-local hooks (.git/hooks/) keep working. pre-commit is handled +# separately (gitleaks + dispatch combined). +readonly DISPATCH_HOOK_NAMES=( + applypatch-msg pre-applypatch post-applypatch + pre-merge-commit prepare-commit-msg commit-msg post-commit + pre-rebase post-checkout post-merge pre-push post-rewrite + pre-auto-gc sendemail-validate post-index-change +) + # Color codes (empty if not a terminal) if [ -t 2 ]; then readonly RED='\033[0;31m' @@ -46,11 +58,30 @@ AUDIT_OK=0 AUDIT_WARN=0 AUDIT_MISS=0 +# Per-tier issue counters. Every audited item belongs to one tier: +# security — protects against a concrete attack vector +# hygiene — operational robustness / forensic readiness +# preference — ecosystem alignment, no security impact +# Only security-tier issues drive the --audit exit code. +AUDIT_TIER="security" +TIER_SECURITY_ISSUES=0 +TIER_HYGIENE_ISSUES=0 +TIER_PREFERENCE_ISSUES=0 + # Whether signing key was found SIGNING_KEY_FOUND=false SIGNING_PUB_PATH="" +# Principal (email) written to allowed_signers — used for the signing smoke test +SIGNING_PRINCIPAL="" + +# OpenSSH client version and the version-appropriate name of the pubkey +# algorithm directive (PubkeyAcceptedAlgorithms >= 8.5, PubkeyAcceptedKeyTypes +# 7.0-8.4, empty = too old / unknown, directive skipped) +OPENSSH_VERSION="" +PUBKEY_ALGOS_DIRECTIVE="PubkeyAcceptedAlgorithms" + # Credential helper detected for this platform DETECTED_CRED_HELPER="" @@ -89,6 +120,65 @@ strip_ssh_value() { printf '%s' "$val" } +# List SSH config files to scan: the main config plus one level of Include +# expansion (globs and ~ resolved; relative paths resolve to ~/.ssh/). +# Deeper nesting is not followed — audit_ssh_config warns when Includes exist. +ssh_config_files() { + [ -f "$SSH_CONFIG" ] || return 0 + printf '%s\n' "$SSH_CONFIG" + + local inc_line + while IFS= read -r inc_line; do + inc_line="$(strip_ssh_value "$inc_line")" + [ -z "$inc_line" ] && continue + local IFS_SAVE="$IFS" + IFS=' ' + local pattern f + for pattern in $inc_line; do + pattern="${pattern/#\~/$HOME}" + case "$pattern" in + /*) ;; + *) pattern="${SSH_DIR}/${pattern}" ;; + esac + # shellcheck disable=SC2086 # Intentional: Include values may glob + for f in $pattern; do + if [ -f "$f" ]; then + printf '%s\n' "$f" + fi + done + done + IFS="$IFS_SAVE" + done </dev/null | sed 's/^[[:space:]]*[Ii][Nn][Cc][Ll][Uu][Dd][Ee][[:space:]=]*//') +EOF +} + +# Print raw IdentityFile values from the main SSH config and one level of +# included files. +list_identity_files() { + local cfg + while IFS= read -r cfg; do + [ -n "$cfg" ] || continue + grep -i '^[[:space:]]*IdentityFile[[:space:]=]' "$cfg" 2>/dev/null | \ + sed 's/^[[:space:]]*[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]=]*//' || true + done <&2 AUDIT_OK=$((AUDIT_OK + 1)) @@ -97,11 +187,28 @@ print_ok() { print_warn() { printf '%b[WARN]%b %s\n' "$YELLOW" "$RESET" "$1" >&2 AUDIT_WARN=$((AUDIT_WARN + 1)) + count_tier_issue } print_miss() { printf '%b[MISS]%b %s\n' "$RED" "$RESET" "$1" >&2 AUDIT_MISS=$((AUDIT_MISS + 1)) + count_tier_issue +} + +# True if the file's first line looks like an SSH *public* key. Guards against +# private key material ending up in allowed_signers or git config. +is_public_key_file() { + local f="$1" + [ -f "$f" ] || return 1 + local first + first="$(head -1 "$f" 2>/dev/null || true)" + case "$first" in + ssh-ed25519\ *|ssh-rsa\ *|ssh-dss\ *|ecdsa-sha2-*|sk-ssh-ed25519*|sk-ecdsa-sha2*) + return 0 ;; + *) + return 1 ;; + esac } print_info() { @@ -186,16 +293,20 @@ 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) - --reset-signing Remove signing key config and optionally delete key files + --audit Run audit only (no changes), exit 0 if no security issues, + 2 if security-tier issues found (hygiene/preference issues + are reported but do not affect the exit code) + -y, --yes Auto-apply all recommended settings (no prompts). + Never deletes files or keys. + --reset-signing Remove signing key config and optionally delete dedicated + signing key files (interactive only — never deletes in -y) --help, -h Show this help message --version Show version Exit codes: - 0 All settings OK, or changes successfully applied + 0 No security issues, or changes successfully applied 1 Error (missing dependencies, etc.) - 2 Audit found issues (--audit mode only) + 2 Audit found security-tier issues (--audit mode only) EOF } @@ -252,6 +363,8 @@ check_dependencies() { die "ssh-keygen is not installed" fi + detect_openssh_version + # Optional: ykman if command -v ykman >/dev/null 2>&1; then HAS_YKMAN=true @@ -266,6 +379,42 @@ check_dependencies() { detect_credential_helper } +# Parse the OpenSSH client version and pick the version-appropriate name for +# the pubkey algorithm directive. An unknown option in ~/.ssh/config makes +# EVERY ssh invocation fail ("Bad configuration option"), so getting the name +# wrong would break all SSH-based git operations. +detect_openssh_version() { + if ! command -v ssh >/dev/null 2>&1; then + PUBKEY_ALGOS_DIRECTIVE="" + print_warn "ssh client not found — skipping SSH algorithm restrictions" + return + fi + + local ver_out major minor + ver_out="$(ssh -V 2>&1 || true)" + if [[ "$ver_out" =~ OpenSSH_([0-9]+)\.([0-9]+) ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + else + # Unknown client (e.g. a non-OpenSSH ssh) — don't risk writing an + # option it may not understand + PUBKEY_ALGOS_DIRECTIVE="" + print_warn "Could not parse OpenSSH version ($ver_out) — skipping SSH algorithm restrictions" + return + fi + + OPENSSH_VERSION="${major}.${minor}" + if (( major > 8 )) || { (( major == 8 )) && (( minor >= 5 )); }; then + PUBKEY_ALGOS_DIRECTIVE="PubkeyAcceptedAlgorithms" + elif (( major >= 7 )); then + # Same option, pre-8.5 spelling + PUBKEY_ALGOS_DIRECTIVE="PubkeyAcceptedKeyTypes" + else + PUBKEY_ALGOS_DIRECTIVE="" + print_warn "OpenSSH ${OPENSSH_VERSION} predates pubkey algorithm restrictions — directive skipped" + fi +} + # 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() { @@ -375,6 +524,7 @@ audit_git_setting() { audit_git_config() { print_header "Identity" + set_tier hygiene audit_git_setting "user.useConfigOnly" "true" # Warn if useConfigOnly would lock out commits (no global identity) @@ -386,11 +536,14 @@ audit_git_config() { fi print_header "Object Integrity" + set_tier security audit_git_setting "transfer.fsckObjects" "true" audit_git_setting "fetch.fsckObjects" "true" audit_git_setting "receive.fsckObjects" "true" audit_git_setting "transfer.bundleURI" "false" + set_tier hygiene audit_git_setting "fetch.prune" "true" + set_tier security print_header "Protocol Restrictions" audit_git_setting "protocol.version" "2" @@ -423,6 +576,7 @@ audit_git_config() { fi print_header "Pull/Merge Hardening" + set_tier hygiene audit_git_setting "pull.ff" "only" audit_git_setting "merge.ff" "only" @@ -434,6 +588,7 @@ audit_git_config() { fi print_header "Transport Security" + set_tier security # url..insteadOf needs special handling local instead_of instead_of="$(git config --global --get 'url.https://.insteadOf' 2>/dev/null || true)" @@ -445,15 +600,27 @@ audit_git_config() { print_warn "url.\"https://\".insteadOf = $instead_of (expected: http://)" fi - audit_git_setting "http.sslVerify" "true" + # http.sslVerify: git's default is already true — unset is NOT the same + # as overridden. Only flag an explicit insecure override. + local ssl_verify + ssl_verify="$(git config --global --get http.sslVerify 2>/dev/null || true)" + if [ -z "$ssl_verify" ]; then + print_ok "http.sslVerify unset (git default: true — not overridden)" + elif [ "$ssl_verify" = "true" ]; then + print_ok "http.sslVerify = true" + else + print_warn "http.sslVerify = $ssl_verify (MITM risk — remove this override)" + fi print_header "Credential Storage" local cred_current cred_current="$(git config --global --get credential.helper 2>/dev/null || true)" if [ -z "$cred_current" ]; then + set_tier hygiene print_miss "credential.helper not set (credentials won't be cached)" + set_tier security elif [ "$cred_current" = "store" ]; then - print_warn "credential.helper = store (INSECURE: stores passwords in plaintext ~/${cred_current})" + print_warn "credential.helper = store (INSECURE: stores passwords in plaintext ~/.git-credentials)" elif is_keychain_credential_helper "$cred_current"; then print_ok "credential.helper = $cred_current (keychain-backed)" elif [ "$cred_current" = "$DETECTED_CRED_HELPER" ]; then @@ -463,14 +630,18 @@ audit_git_config() { fi print_header "Defaults" + set_tier preference audit_git_setting "init.defaultBranch" "main" print_header "Forensic Readiness" + set_tier hygiene audit_git_setting "gc.reflogExpire" "180.days" audit_git_setting "gc.reflogExpireUnreachable" "90.days" print_header "Visibility" + set_tier preference audit_git_setting "log.showSignature" "true" + set_tier security } audit_precommit_hook() { @@ -489,10 +660,33 @@ audit_precommit_hook() { fi if grep -q 'gitleaks' "$hook_path" 2>/dev/null; then - print_ok "Pre-commit hook with gitleaks at $hook_path" + if grep -q 'git-harden.sh' "$hook_path" 2>/dev/null && \ + ! grep -q 'local_hook' "$hook_path" 2>/dev/null; then + print_warn "Pre-commit hook predates repo-local dispatch — re-run without --audit to upgrade" + else + print_ok "Pre-commit hook with gitleaks at $hook_path" + fi else print_warn "Pre-commit hook exists but does not reference gitleaks (user-managed)" fi + + # If hooks are globally redirected, repo-local hooks only keep working + # via dispatch stubs + local hooks_path_cfg + hooks_path_cfg="$(git config --global --get core.hooksPath 2>/dev/null || true)" + if [ -n "$hooks_path_cfg" ]; then + local missing=0 name + for name in "${DISPATCH_HOOK_NAMES[@]}"; do + [ -f "${HOOKS_DIR}/${name}" ] || missing=$((missing + 1)) + done + if (( missing > 0 )); then + set_tier hygiene + print_warn "core.hooksPath is set but ${missing} dispatch stub(s) are missing — repo-local hooks (husky, lefthook, pre-commit framework) will not run" + set_tier security + else + print_ok "Dispatch stubs present for ${#DISPATCH_HOOK_NAMES[@]} hook types (repo-local hooks keep working)" + fi + fi } audit_global_gitignore() { @@ -575,26 +769,25 @@ audit_ssh_key_hygiene() { 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 </dev/null | sed 's/^[[:space:]]*[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]=]*//') + # Also collect keys from IdentityFile directives in ~/.ssh/config and + # one level of Include-d files + 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 </dev/null | head -1 | sed 's/^[[:space:]]*[^[:space:]=]*[[:space:]=]*//' || true)" - current="$(strip_ssh_value "$current")" + current="$(get_ssh_directive_value "$directive")" if [ -z "$current" ]; then - print_miss "SSH: $directive (expected: $expected)" + if grep -qi "^[[:space:]]*${directive}[[:space:]=]" "$SSH_CONFIG" 2>/dev/null; then + print_warn "SSH: $directive set only in host-specific blocks — no global default (expected: $expected)" + else + print_miss "SSH: $directive (expected: $expected)" + fi elif [ "$current" = "$expected" ]; then print_ok "SSH: $directive = $current" else @@ -694,21 +893,37 @@ audit_ssh_config() { print_header "SSH Configuration" if [ ! -f "$SSH_CONFIG" ]; then + set_tier security print_miss "$SSH_CONFIG does not exist" return fi + if grep -qiE '^[[:space:]]*include[[:space:]=]' "$SSH_CONFIG" 2>/dev/null; then + print_info "SSH config uses Include — directives inside included files are not audited or modified (key files in them are scanned)" + fi + + set_tier security audit_ssh_directive "StrictHostKeyChecking" "accept-new" + set_tier hygiene audit_ssh_directive "HashKnownHosts" "yes" + set_tier security audit_ssh_directive "IdentitiesOnly" "yes" + set_tier hygiene 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" + set_tier security + if [ -n "$PUBKEY_ALGOS_DIRECTIVE" ]; then + audit_ssh_directive "$PUBKEY_ALGOS_DIRECTIVE" "$PUBKEY_ALGO_LIST" + fi } 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 + printf ' by tier: %bsecurity: %d%b / hygiene: %d / preference: %d\n' \ + "$( (( TIER_SECURITY_ISSUES > 0 )) && printf '%s' "$RED" )" \ + "$TIER_SECURITY_ISSUES" "$RESET" \ + "$TIER_HYGIENE_ISSUES" "$TIER_PREFERENCE_ISSUES" >&2 if [ $((AUDIT_WARN + AUDIT_MISS)) -gt 0 ]; then return 2 @@ -730,6 +945,11 @@ backup_git_config() { timestamp="$(date +%Y%m%d-%H%M%S)" local backup_file="${BACKUP_DIR}/pre-harden-backup-${timestamp}.txt" + # The config dump can contain secrets (http.extraHeader auth, tokens in + # insteadOf URLs) — restrict permissions before writing any content + touch "$backup_file" + chmod 600 "$backup_file" + { echo "# git-harden.sh backup — $timestamp" echo "# Global git config snapshot" @@ -845,10 +1065,10 @@ apply_git_config() { if setting_needs_change "core.hooksPath" "$hooks_path_val"; then print_header "Global Hooks Path" printf ' %bWarning:%b Setting core.hooksPath redirects ALL hook execution to a\n' "$YELLOW" "$RESET" >&2 - printf ' central directory. Per-repo hooks (.git/hooks/) will stop running.\n' >&2 - printf ' This includes hooks from frameworks like husky, lefthook, and pre-commit.\n\n' >&2 - printf ' Recommended: set this, then install a dispatch hook that calls per-repo\n' >&2 - printf ' hooks when present (this script installs a gitleaks hook there).\n\n' >&2 + printf ' central directory, so per-repo hooks (.git/hooks/) no longer run directly.\n' >&2 + printf ' To keep frameworks like husky, lefthook, and pre-commit working, this\n' >&2 + printf ' script installs dispatch stubs there that forward every hook type to the\n' >&2 + printf ' repository'\''s own hooks (offered in the next step).\n\n' >&2 printf ' core.hooksPath = %s\n\n' "$hooks_path_val" >&2 if prompt_yn "Set core.hooksPath? (overrides per-repo hooks)"; then git config --global core.hooksPath "$hooks_path_val" @@ -987,55 +1207,133 @@ apply_git_config() { "gc.reflogExpireUnreachable" "90.days" "Keep unreachable reflog 90 days (default: 30)" } +# Write the combined gitleaks + repo-local-dispatch pre-commit hook. +write_precommit_hook() { + local hook_path="$1" + mkdir -p "$HOOKS_DIR" + cat > "$hook_path" << 'HOOK_EOF' +#!/usr/bin/env bash +# Installed by git-harden.sh — global pre-commit: secret scan + dispatch. +# Runs gitleaks on the staged diff, then dispatches to the repository's own +# pre-commit hook (.git/hooks/pre-commit), which core.hooksPath would +# otherwise silently disable. +# To bypass the secret scan for a single commit: SKIP_GITLEAKS=1 git commit +set -o errexit +set -o nounset +set -o pipefail + +if [ "${SKIP_GITLEAKS:-0}" = "1" ]; then + : +elif command -v gitleaks >/dev/null 2>&1; then + gitleaks protect --staged --redact --verbose +else + printf 'git-harden pre-commit: gitleaks not installed — secret scan SKIPPED\n' >&2 + printf ' Install it: brew install gitleaks (macOS) or https://github.com/gitleaks/gitleaks\n' >&2 +fi + +# Dispatch to the repo-local hook so frameworks (husky, lefthook, +# pre-commit) keep working. Deliberately uses .git/hooks directly: +# `git rev-parse --git-path hooks` would resolve back to THIS directory. +git_dir="$(git rev-parse --git-dir 2>/dev/null)" || exit 0 +local_hook="${git_dir}/hooks/pre-commit" +if [ -x "$local_hook" ]; then + exec "$local_hook" "$@" +fi +exit 0 +HOOK_EOF + chmod +x "$hook_path" + print_info "Installed gitleaks + dispatch pre-commit hook at $hook_path" +} + 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 + # Our pre-dispatch hook version silently disabled repo-local + # hooks — offer the upgrade + if grep -q 'git-harden.sh' "$hook_path" 2>/dev/null && \ + ! grep -q 'local_hook' "$hook_path" 2>/dev/null; then + if prompt_yn "Upgrade git-harden pre-commit hook to also dispatch to repo-local hooks?"; then + write_precommit_hook "$hook_path" + fi + fi 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 + if ! command -v gitleaks >/dev/null 2>&1; then print_warn "gitleaks not found — install it for pre-commit secret scanning:" printf ' macOS: brew install gitleaks\n' >&2 printf ' Linux: apt install gitleaks / dnf 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" + write_precommit_hook "$hook_path" fi } +# Install thin dispatch stubs for every client-side hook type so that +# redirecting core.hooksPath does not silently disable repo-local hooks +# (the stub forwards to .git/hooks/ when present and executable). +apply_dispatch_hooks() { + # Only relevant when hooks are globally redirected to our directory + local hooks_path_cfg + hooks_path_cfg="$(git config --global --get core.hooksPath 2>/dev/null || true)" + local expanded_cfg="${hooks_path_cfg/#\~/$HOME}" + if [ "$expanded_cfg" != "$HOOKS_DIR" ]; then + return 0 + fi + + local missing=() name + for name in "${DISPATCH_HOOK_NAMES[@]}"; do + if [ ! -f "${HOOKS_DIR}/${name}" ]; then + missing+=("$name") + fi + done + + if [ ${#missing[@]} -eq 0 ]; then + return 0 + fi + + print_header "Repo-local Hook Dispatch" + printf ' core.hooksPath redirects ALL hooks to %s.\n' "$HOOKS_DIR" >&2 + printf ' Dispatch stubs forward each hook type to the repository'\''s own\n' >&2 + printf ' .git/hooks/ so frameworks like husky, lefthook and pre-commit\n' >&2 + printf ' keep working. Missing stubs: %d\n\n' "${#missing[@]}" >&2 + + if ! prompt_yn "Install dispatch stubs for ${#missing[@]} hook type(s)?"; then + print_warn "Without dispatch stubs, repo-local hooks will NOT run while core.hooksPath is set" + return 0 + fi + + mkdir -p "$HOOKS_DIR" + for name in "${missing[@]}"; do + cat > "${HOOKS_DIR}/${name}" << 'DISPATCH_EOF' +#!/usr/bin/env bash +# Installed by git-harden.sh — dispatch stub. +# core.hooksPath redirects all hooks to this directory; this stub forwards +# to the repository's own hook so repo-local hooks keep working. +# Deliberately uses .git/hooks directly: `git rev-parse --git-path hooks` +# would resolve back to THIS directory and recurse. +set -o nounset +hook_name="$(basename "$0")" +git_dir="$(git rev-parse --git-dir 2>/dev/null)" || exit 0 +local_hook="${git_dir}/hooks/${hook_name}" +if [ -x "$local_hook" ]; then + exec "$local_hook" "$@" +fi +exit 0 +DISPATCH_EOF + chmod +x "${HOOKS_DIR}/${name}" + done + print_info "Installed ${#missing[@]} dispatch stub(s) in $HOOKS_DIR" +} + apply_global_gitignore() { print_header "Global Gitignore" @@ -1145,11 +1443,22 @@ detect_existing_keys() { if [ -n "$configured_key" ]; then local expanded_key expanded_key="${configured_key/#\~/$HOME}" - if [ -f "$expanded_key" ]; then + # git accepts a PRIVATE key path in user.signingkey — never treat one + # as the public key (it would end up cat'ed into allowed_signers) + if is_public_key_file "$expanded_key"; then SIGNING_KEY_FOUND=true SIGNING_PUB_PATH="$expanded_key" return fi + if [ -f "$expanded_key" ] && is_public_key_file "${expanded_key}.pub"; then + print_warn "user.signingkey points to a private key — using ${expanded_key}.pub instead" + SIGNING_KEY_FOUND=true + SIGNING_PUB_PATH="${expanded_key}.pub" + return + fi + if [ -f "$expanded_key" ]; then + print_warn "user.signingkey = $configured_key is not a public key file — ignoring it" + fi fi # Check common ed25519 key locations (dedicated signing keys first, then general) @@ -1165,34 +1474,33 @@ detect_existing_keys() { 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 - # Strip inline comments and quotes - identity_path="$(strip_ssh_value "$identity_path")" - [ -z "$identity_path" ] && continue - # Expand tilde safely - identity_path="${identity_path/#\~/$HOME}" + # Check IdentityFile directives in ~/.ssh/config (and one level of + # Include-d files) for custom-named keys + local identity_path + while IFS= read -r identity_path; do + # Strip inline comments and quotes + identity_path="$(strip_ssh_value "$identity_path")" + [ -z "$identity_path" ] && continue + # Expand tilde safely + identity_path="${identity_path/#\~/$HOME}" - pub_path="${identity_path}.pub" - if [ -f "$pub_path" ]; then - # Only use ed25519, ed25519-sk, or ecdsa-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*|sk-ecdsa-sha2*) - SIGNING_KEY_FOUND=true + pub_path="${identity_path}.pub" + if [ -f "$pub_path" ]; then + # Only use ed25519, ed25519-sk, or ecdsa-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*|sk-ecdsa-sha2*) + SIGNING_KEY_FOUND=true - 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:]=]*//') + SIGNING_PUB_PATH="$pub_path" + return + ;; + esac + fi + done <&2 + printf '\n Signing key files found:\n' >&2 local kf for kf in "${key_files[@]}"; do printf ' %s\n' "$kf" >&2 done - if prompt_yn "Delete these key files? (No = keep as .bak)"; then + # Deleting keys is irreversible — never do it without an explicit, + # interactive yes (prompt_yn auto-accepts in -y mode, so guard first) + if [ "$AUTO_YES" = true ]; then + print_info "Key files left in place (-y mode never deletes keys). Re-run interactively to remove them." + elif prompt_yn "Delete these key files? (irreversible)" "n"; then for kf in "${key_files[@]}"; do rm -f "$kf" done print_info "Key files deleted" - else + elif prompt_yn "Rename them with a ${backup_suffix} suffix instead? (No = leave untouched)" "n"; then for kf in "${key_files[@]}"; do mv "$kf" "${kf}${backup_suffix}" done - print_info "Key files backed up with suffix ${backup_suffix}" + print_info "Key files renamed with suffix ${backup_suffix}" + else + print_info "Key files left untouched" fi else - print_info "No signing key files found" + print_info "No dedicated signing key files found" fi } @@ -1368,12 +1687,59 @@ reset_signing() { # and forceSignAnnotated in one step (no individual prompts). enable_signing() { local pub_path="$1" + if ! is_public_key_file "$pub_path"; then + print_warn "$pub_path does not look like an SSH public key — not enabling signing" + return + fi 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 + verify_signing_setup "$pub_path" +} + +# Smoke-test the signing setup: sign a test message and verify it against +# allowed_signers with the recorded principal. Catches the "Good signature +# but No principal matched" misconfiguration at setup time instead of in +# every future `git log`. +verify_signing_setup() { + local pub_path="$1" + local priv_path="${pub_path%.pub}" + + # Signing may require a hardware-key touch or a passphrase — never + # attempt it in non-interactive mode + if [ "$AUTO_YES" = true ]; then + return 0 + fi + if [ -z "$SIGNING_PRINCIPAL" ] || [ ! -f "$priv_path" ] || [ ! -f "$ALLOWED_SIGNERS_FILE" ]; then + return 0 + fi + if ! prompt_yn "Verify signing works now? (may require a key touch or passphrase)"; then + return 0 + fi + + local tmpdir + tmpdir="$(mktemp -d -t git-harden-verify.XXXXXX)" + printf 'git-harden signing verification\n' > "${tmpdir}/msg" + + local verify_ok=false + # Keep sign stderr visible — it carries the touch/passphrase prompts + if ssh-keygen -Y sign -n git -f "$priv_path" "${tmpdir}/msg" >/dev/null && \ + ssh-keygen -Y verify -n git -f "$ALLOWED_SIGNERS_FILE" -I "$SIGNING_PRINCIPAL" \ + -s "${tmpdir}/msg.sig" < "${tmpdir}/msg" >/dev/null 2>&1; then + verify_ok=true + fi + rm -rf "$tmpdir" + + if [ "$verify_ok" = true ]; then + print_info "Signature round-trip verified: key signs and allowed_signers matches principal ${SIGNING_PRINCIPAL}" + else + print_warn "Signature verification failed — commits will be signed, but verification will show 'No principal matched'" + printf ' Check that the email in %s matches the email on your commits\n' "$ALLOWED_SIGNERS_FILE" >&2 + printf ' (repos overriding user.email need their own allowed_signers entry).\n' >&2 + fi } generate_ssh_key() { @@ -1568,9 +1934,14 @@ generate_fido2_key() { local key_path="" key_type_label="" resident="" local keygen_stderr keygen_rc - local attempt_num=0 i + local attempt_num=0 + # While loop with a manual index: "retry the same attempt" must NOT + # advance to the next fallback (a for-in loop reassigns its variable on + # every iteration, which silently broke the retry) + local i=0 + local num_attempts=${#attempt_types[@]} - for i in "${!attempt_types[@]}"; do + while (( i < num_attempts )); do key_type_label="${attempt_types[$i]}" key_path="${attempt_paths[$i]}" resident="${attempt_resident[$i]}" @@ -1621,19 +1992,19 @@ generate_fido2_key() { if [[ "$retry_reply" = "q" ]]; then return fi - # Retry the same attempt (back up the index) - i=$((i - 1)) + # Retry the same attempt: leave i unchanged attempt_num=$((attempt_num - 1)) continue fi - # Check for recoverable errors worth retrying with next attempt + # Check for recoverable errors worth retrying with the next attempt if printf '%s' "$keygen_stderr" | grep -qi 'feature not supported\|unknown key type\|not supported\|invalid format'; then # Clean up any partial files before next attempt rm -f "$key_path" "${key_path}.pub" # Brief pause to let the authenticator reset its CTAP2 state # (back-to-back requests can cause spurious "invalid format") sleep 1 + i=$((i + 1)) continue fi @@ -1659,6 +2030,12 @@ setup_allowed_signers() { return fi + # Never write anything but public key material into allowed_signers + if ! is_public_key_file "$SIGNING_PUB_PATH"; then + print_warn "$SIGNING_PUB_PATH does not look like an SSH public key — refusing to add it to allowed_signers" + return + fi + local email email="$(git config --global --get user.email 2>/dev/null || true)" if [[ -z "$email" ]]; then @@ -1674,6 +2051,8 @@ setup_allowed_signers() { fi fi + SIGNING_PRINCIPAL="$email" + mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" local pub_key @@ -1695,14 +2074,61 @@ setup_allowed_signers() { # SSH config hardening # ------------------------------------------------------------------------------ -# Read the current value of an SSH config directive (empty if absent). +# Read the current GLOBAL value of an SSH config directive (empty if absent). +# Global scope = top-level lines (before any Host/Match block) or lines inside +# a "Host *" block. Directives inside host-specific blocks do not apply +# globally and are deliberately ignored here. get_ssh_directive_value() { local directive="$1" + [ -f "$SSH_CONFIG" ] || return 0 local raw - raw="$(grep -i "^[[:space:]]*${directive}[[:space:]=]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^[:space:]=]*[[:space:]=]*//' || true)" + raw="$(awk -v d="$(printf '%s' "$directive" | tr '[:upper:]' '[:lower:]')" ' + function ltrim(s) { sub(/^[ \t]+/, "", s); return s } + { + line = ltrim($0) + lower = tolower(line) + } + lower ~ /^host[ \t=]/ { + rest = substr(line, 5) + sub(/^[ \t=]+/, "", rest) + in_block = 1 + global_block = (rest == "*") ? 1 : 0 + next + } + lower ~ /^match[ \t=]/ { in_block = 1; global_block = 0; next } + in_block && !global_block { next } + index(lower, d) == 1 { + sep = substr(lower, length(d) + 1, 1) + if (sep == " " || sep == "\t" || sep == "=") { + val = substr(line, length(d) + 1) + sub(/^[ \t=]+/, "", val) + print val + exit + } + } + ' "$SSH_CONFIG" 2>/dev/null || true)" strip_ssh_value "$raw" } +# True if the last Host/Match block in the SSH config is exactly "Host *" +# (meaning new directives can be appended at EOF and land in global scope). +last_host_block_is_global() { + awk ' + function ltrim(s) { sub(/^[ \t]+/, "", s); return s } + { + line = ltrim($0) + lower = tolower(line) + } + lower ~ /^host[ \t=]/ { + rest = substr(line, 5) + sub(/^[ \t=]+/, "", rest) + last = (rest == "*") ? 1 : 0 + } + lower ~ /^match[ \t=]/ { last = 0 } + END { exit last ? 0 : 1 } + ' "$SSH_CONFIG" 2>/dev/null +} + ssh_directive_needs_change() { local directive="$1" local value="$2" @@ -1717,55 +2143,65 @@ apply_single_ssh_directive() { current="$(get_ssh_directive_value "$directive")" if [ -n "$current" ]; then - # Replace existing directive + # Replace the first GLOBAL-scope occurrence (top-level or inside a + # "Host *" block). Occurrences inside host-specific blocks are left + # alone — rewriting those would change behavior for that host only + # while the global default stayed unset. local tmpfile tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")" - local replaced=false + local replaced=false in_global=true line indent 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 + if printf '%s' "$line" | grep -qiE '^[[:space:]]*host[[:space:]=]'; then + if printf '%s' "$line" | grep -qE '^[[:space:]]*[Hh][Oo][Ss][Tt][[:space:]=]+\*[[:space:]]*$'; then + in_global=true + else + in_global=false + fi printf '%s\n' "$line" + continue fi + if printf '%s' "$line" | grep -qiE '^[[:space:]]*match[[:space:]=]'; then + in_global=false + printf '%s\n' "$line" + continue + fi + if [ "$replaced" = false ] && [ "$in_global" = true ] && \ + printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]=]"; then + indent="${line%%[![:space:]]*}" + printf '%s%s %s\n' "$indent" "$directive" "$value" + replaced=true + continue + fi + printf '%s\n' "$line" done < "$SSH_CONFIG" > "$tmpfile" mv "$tmpfile" "$SSH_CONFIG" chmod 600 "$SSH_CONFIG" - else - # Append inside a Host * block so it applies globally. - # If no Host * block exists, prepend one before the first Host/Match block - # (or append to EOF if the file has no blocks at all). - if grep -qE '^[[:space:]]*Host[[:space:]]+\*[[:space:]]*$' "$SSH_CONFIG" 2>/dev/null; then - # Insert after the "Host *" line - local tmpfile - tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")" - local inserted=false - while IFS= read -r line || [[ -n "$line" ]]; do - printf '%s\n' "$line" - if [[ "$inserted" = false ]] && printf '%s' "$line" | grep -qE '^[[:space:]]*Host[[:space:]]+\*[[:space:]]*$'; then - printf ' %s %s\n' "$directive" "$value" - inserted=true - fi - done < "$SSH_CONFIG" > "$tmpfile" - mv "$tmpfile" "$SSH_CONFIG" - chmod 600 "$SSH_CONFIG" - elif grep -qEi '^[[:space:]]*(Host|Match)[[:space:]]' "$SSH_CONFIG" 2>/dev/null; then - # File has Host/Match blocks but no Host *. Prepend a Host * section. - local tmpfile - tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")" - { - printf 'Host *\n' - printf ' %s %s\n' "$directive" "$value" - printf '\n' - cat "$SSH_CONFIG" - } > "$tmpfile" - mv "$tmpfile" "$SSH_CONFIG" - chmod 600 "$SSH_CONFIG" - else - # No blocks at all — safe to append bare - printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG" - fi + return 0 fi + + # Directive not set globally — append at EOF. ssh uses first-obtained-wins + # semantics, so appending keeps every existing (earlier) host-specific and + # Host * setting authoritative; the new value only fills the gap. + # Make sure the file ends with a newline before appending. + if [ -s "$SSH_CONFIG" ] && [ -n "$(tail -c 1 "$SSH_CONFIG")" ]; then + printf '\n' >> "$SSH_CONFIG" + fi + + if ! grep -qiE '^[[:space:]]*(host|match)[[:space:]=]' "$SSH_CONFIG" 2>/dev/null; then + # No blocks at all — safe to append bare (top-level = global) + printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG" + elif last_host_block_is_global; then + # File ends inside a "Host *" block — appending lands in global scope + printf ' %s %s\n' "$directive" "$value" >> "$SSH_CONFIG" + else + # Start a new global defaults block at EOF + { + printf '\n# Added by git-harden.sh — global defaults (blocks above take precedence)\n' + printf 'Host *\n' + printf ' %s %s\n' "$directive" "$value" + } >> "$SSH_CONFIG" + fi + chmod 600 "$SSH_CONFIG" } apply_ssh_directive_group() { @@ -1811,6 +2247,38 @@ apply_ssh_directive_group() { fi } +# Print the key types of all available SSH keys: on-disk pubkeys plus keys +# loaded in the SSH agent (covers agent-backed setups like 1Password where no +# private key exists on disk). +list_ssh_key_types() { + local f + for f in "${SSH_DIR}"/*.pub; do + if [ -f "$f" ]; then + awk '{print $1}' "$f" 2>/dev/null || true + fi + done + if command -v ssh-add >/dev/null 2>&1; then + ssh-add -L 2>/dev/null | awk '{print $1}' || true + fi +} + +# True if at least one key passes the hardened algorithm policy. +has_modern_ssh_key() { + local t + while IFS= read -r t; do + case "$t" in + ssh-ed25519|sk-ssh-ed25519*|ecdsa-sha2-*|sk-ecdsa-sha2*) return 0 ;; + esac + done < 0 )); then + exit 2 + fi + exit 0 fi # If everything is already OK, nothing to do @@ -1965,6 +2465,7 @@ main() { backup_git_config apply_git_config apply_precommit_hook + apply_dispatch_hooks apply_global_gitignore apply_signing_config apply_ssh_config diff --git a/test/git-harden.bats b/test/git-harden.bats index f600373..08c37c8 100755 --- a/test/git-harden.bats +++ b/test/git-harden.bats @@ -1279,7 +1279,7 @@ SSHEOF grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config" } -@test "apply inserts into existing Host * block" { +@test "apply adds global directive without shadowing host-specific blocks" { cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF' Host * HashKnownHosts yes @@ -1291,12 +1291,48 @@ SSHEOF source_functions apply_single_ssh_directive "IdentitiesOnly" "yes" - # Should be inside Host * block (indented), not appended bare grep -q "IdentitiesOnly yes" "${TEST_HOME}/.ssh/config" - # Only one Host * line - local count - count="$(grep -c '^Host \*$' "${TEST_HOME}/.ssh/config")" - [ "$count" -eq 1 ] + + # ssh uses first-obtained-wins semantics: the new directive must land + # AFTER the host-specific blocks (in a new Host * block at EOF) so it + # cannot override per-host settings. Inserting into the top Host * + # block would shadow every block below it. + local directive_line github_line + directive_line="$(grep -n 'IdentitiesOnly yes' "${TEST_HOME}/.ssh/config" | cut -d: -f1)" + github_line="$(grep -n '^Host github.com$' "${TEST_HOME}/.ssh/config" | cut -d: -f1)" + [ "$directive_line" -gt "$github_line" ] + + # The directive is recognized as the global value + [ "$(get_ssh_directive_value IdentitiesOnly)" = "yes" ] +} + +@test "apply replaces directive in global scope, not in host blocks" { + cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF' +Host legacy.example.com + StrictHostKeyChecking yes + +Host * + StrictHostKeyChecking ask +SSHEOF + + source_functions + apply_single_ssh_directive "StrictHostKeyChecking" "accept-new" + + # The host-specific value must be untouched + grep -q "StrictHostKeyChecking yes" "${TEST_HOME}/.ssh/config" + # The global (Host *) value must be replaced + grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config" + ! grep -q "StrictHostKeyChecking ask" "${TEST_HOME}/.ssh/config" +} + +@test "audit treats host-specific directive as not set globally" { + cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF' +Host github.com + IdentitiesOnly yes +SSHEOF + + source_functions + [ -z "$(get_ssh_directive_value IdentitiesOnly)" ] } @test "apply appends bare when no Host/Match blocks exist" { @@ -1435,8 +1471,11 @@ SSHEOF sigkey="$(git config --global --get user.signingkey 2>/dev/null || true)" [ -z "$sigkey" ] - # Key files should be listed for cleanup + # Not a dedicated *_signing key: it may double as an auth key, so the + # files must be mentioned but left untouched assert_output --partial "my_org_key" + [ -f "${TEST_HOME}/.ssh/my_org_key" ] + [ -f "${TEST_HOME}/.ssh/my_org_key.pub" ] } @test "reset-signing includes dedicated signing key names" { @@ -1452,10 +1491,201 @@ SSHEOF } # =========================================================================== -# v0.5.0: Version bump +# v0.6.0: reset-signing never touches general-purpose keys # =========================================================================== -@test "--version reports 0.5.0" { - run bash "$SCRIPT" --version - assert_output --partial "0.5.0" +@test "reset-signing never lists general-purpose keys for deletion" { + # id_ed25519 is the user's likely SSH AUTH key — must never be a + # deletion candidate even though detect_existing_keys can select it + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q + + source_functions + AUTO_YES=true + + run reset_signing + assert_success + refute_output --partial "Signing key files found" + [ -f "${TEST_HOME}/.ssh/id_ed25519" ] + [ -f "${TEST_HOME}/.ssh/id_ed25519.pub" ] +} + +@test "reset-signing -y never deletes dedicated signing key files" { + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q + git config --global user.signingkey "${TEST_HOME}/.ssh/id_ed25519_signing.pub" + + source_functions + AUTO_YES=true + + run reset_signing + assert_success + assert_output --partial "left in place" + [ -f "${TEST_HOME}/.ssh/id_ed25519_signing" ] + [ -f "${TEST_HOME}/.ssh/id_ed25519_signing.pub" ] +} + +# =========================================================================== +# v0.6.0: signing key must be a public key +# =========================================================================== + +@test "detect_existing_keys recovers when signingkey points at private key" { + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q + git config --global user.signingkey "${TEST_HOME}/.ssh/id_ed25519_signing" + + source_functions + detect_existing_keys + + [ "$SIGNING_KEY_FOUND" = "true" ] + [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_signing.pub" ] +} + +@test "setup_allowed_signers refuses private key material" { + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q + + source_functions + SIGNING_PUB_PATH="${TEST_HOME}/.ssh/id_ed25519_signing" # private key! + + run setup_allowed_signers + assert_output --partial "refusing" + [ ! -f "${TEST_HOME}/.config/git/allowed_signers" ] +} + +# =========================================================================== +# v0.6.0: dispatch stubs keep repo-local hooks working +# =========================================================================== + +@test "apply_dispatch_hooks installs stubs when hooksPath is ours" { + git config --global core.hooksPath "~/.config/git/hooks" + + source_functions + AUTO_YES=true + + run apply_dispatch_hooks + assert_success + [ -x "${TEST_HOME}/.config/git/hooks/pre-push" ] + [ -x "${TEST_HOME}/.config/git/hooks/commit-msg" ] + [ -x "${TEST_HOME}/.config/git/hooks/post-checkout" ] +} + +@test "apply_dispatch_hooks is a no-op when hooksPath is not set" { + source_functions + AUTO_YES=true + + run apply_dispatch_hooks + assert_success + [ ! -f "${TEST_HOME}/.config/git/hooks/pre-push" ] +} + +@test "global pre-commit hook dispatches to repo-local hook" { + source_functions + write_precommit_hook "${TEST_HOME}/.config/git/hooks/pre-commit" + + mkdir -p "${TEST_HOME}/repo" + cd "${TEST_HOME}/repo" + git init -q + cat > .git/hooks/pre-commit <<'LOCALEOF' +#!/usr/bin/env bash +touch .local-hook-ran +LOCALEOF + chmod +x .git/hooks/pre-commit + + run env SKIP_GITLEAKS=1 "${TEST_HOME}/.config/git/hooks/pre-commit" + assert_success + [ -f .local-hook-ran ] +} + +@test "global pre-commit hook warns when gitleaks missing" { + source_functions + write_precommit_hook "${TEST_HOME}/.config/git/hooks/pre-commit" + + mkdir -p "${TEST_HOME}/repo" + cd "${TEST_HOME}/repo" + git init -q + + # PATH without gitleaks (keep git/bash/coreutils) + run env PATH="/usr/bin:/bin" "${TEST_HOME}/.config/git/hooks/pre-commit" + assert_success + assert_output --partial "secret scan SKIPPED" +} + +# =========================================================================== +# v0.6.0: audit tiers +# =========================================================================== + +@test "audit exits 0 when only preference/hygiene issues remain" { + # Apply every security-tier setting, leave init.defaultBranch and + # log.showSignature (preference) and reflog/prune (hygiene) unset + git config --global transfer.fsckObjects true + git config --global fetch.fsckObjects true + git config --global receive.fsckObjects true + git config --global transfer.bundleURI false + git config --global protocol.version 2 + git config --global protocol.allow never + git config --global protocol.https.allow always + git config --global protocol.ssh.allow always + git config --global protocol.file.allow user + git config --global protocol.git.allow never + 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 core.symlinks false + git config --global core.hooksPath "~/.config/git/hooks" + 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 'url.https://.insteadOf' 'http://' + git config --global credential.helper osxkeychain + 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 + ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q + git config --global user.signingkey "${TEST_HOME}/.ssh/id_ed25519_signing.pub" + + # Hooks (with dispatch stubs), gitignore, SSH config + source_functions + write_precommit_hook "${TEST_HOME}/.config/git/hooks/pre-commit" + AUTO_YES=true + apply_dispatch_hooks + printf '.env\n*.pem\n*.key\n' > "${TEST_HOME}/.config/git/ignore" + git config --global core.excludesFile "~/.config/git/ignore" + cat > "${TEST_HOME}/.ssh/config" <