feat: v0.2.0 expanded hardening

Add gitleaks pre-commit hook, global gitignore, plaintext credential
detection, SSH key hygiene audit, 8 new git config settings, and
safe.directory wildcard detection. Fix ssh-keygen macOS compatibility,
FIDO2 detection via ioreg, and interactive test isolation.

Implements docs/specs/2026-03-31-v0.2.0-expanded-hardening.md

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Flo
2026-03-31 14:03:29 +02:00
parent 0e6d04fefb
commit 8037cb7908
11 changed files with 2019 additions and 65 deletions

View File

@@ -4,7 +4,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.1.0] - 2026-03-31 ## [0.2.0] - 2026-03-31
### Added
- Gitleaks pre-commit hook installation — creates `~/.config/git/hooks/pre-commit` with `SKIP_GITLEAKS` bypass
- Global gitignore creation (`~/.config/git/ignore`) with security patterns (`.env`, `*.pem`, `*.key`, credentials, Terraform state)
- Audit of existing global gitignore for missing security patterns
- 8 new git config settings: `user.useConfigOnly`, `protocol.version=2`, `transfer.bundleURI=false`, `init.defaultBranch=main`, `core.symlinks=false` (interactive-only), `fetch.prune=true`, `gc.reflogExpire=180.days`, `gc.reflogExpireUnreachable=90.days`
- Combined signing enablement into single prompt (replaces 3 individual prompts)
- 26 new BATS tests (90 total)
### Security
- SSH key hygiene audit — scans `~/.ssh/*.pub` and `IdentityFile` entries, warns about DSA/ECDSA/weak RSA keys
- Plaintext credential file detection — warns about `~/.git-credentials`, `~/.netrc`, `~/.npmrc` (auth tokens), `~/.pypirc` (passwords)
- `safe.directory = *` wildcard detection and removal (CVE-2022-24765)
### Fixed
- `ssh-keygen` calls fail on macOS with `--` end-of-options separator (removed)
- Interactive tests fail on macOS due to tmux resetting `HOME` in login shells
- Interactive tests race condition with tmux session cleanup between tests
## [0.1.0] - 2026-03-30
### Added ### Added
- Interactive shell script that audits and hardens global git config - Interactive shell script that audits and hardens global git config
@@ -32,5 +52,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- SSH config value parsing handles inline comments and quoted paths - SSH config value parsing handles inline comments and quoted paths
- Version comparison uses base-10 arithmetic to prevent octal interpretation - Version comparison uses base-10 arithmetic to prevent octal interpretation
- Temp file cleanup trap in SSH config updates - Temp file cleanup trap in SSH config updates
- `--` separator before path arguments in `ssh-keygen` calls
- Removed unused exported `SIGNING_KEY_PATH` variable

View File

@@ -38,16 +38,23 @@ The script runs in two phases:
| Category | What it does | | Category | What it does |
|---|---| |---|---|
| **Object integrity** | Validates all objects on fetch/push/receive (`transfer.fsckObjects`, etc.) | | **Identity** | `user.useConfigOnly=true` — prevents commits without explicit identity |
| **Protocol restrictions** | Default-deny policy: only HTTPS and SSH allowed. Blocks `git://` (unencrypted) and `ext://` (arbitrary command execution) | | **Object integrity** | `fsckObjects` on transfer/fetch/receive, `transfer.bundleURI=false`, `fetch.prune=true` |
| **Filesystem protection** | Enables `core.protectNTFS`, `core.protectHFS`, disables `core.fsmonitor` | | **Protocol restrictions** | Default-deny policy: only HTTPS and SSH. Blocks `git://` and `ext://`. Forces `protocol.version=2` |
| **Filesystem protection** | `core.protectNTFS`, `core.protectHFS`, `core.fsmonitor=false`, `core.symlinks=false` (interactive-only) |
| **Hook control** | Redirects `core.hooksPath` to `~/.config/git/hooks` so repo-local hooks can't execute | | **Hook control** | Redirects `core.hooksPath` to `~/.config/git/hooks` so repo-local hooks can't execute |
| **Repository safety** | `safe.bareRepository=explicit`, `submodule.recurse=false` | | **Pre-commit hook** | Installs gitleaks secret scanner as global pre-commit hook (with `SKIP_GITLEAKS` bypass) |
| **Pull/merge hardening** | `pull.ff=only`, `merge.ff=only` — refuses non-fast-forward merges, surfacing rewritten history | | **Repository safety** | `safe.bareRepository=explicit`, `submodule.recurse=false`, detects/removes `safe.directory=*` wildcard |
| **Pull/merge hardening** | `pull.ff=only`, `merge.ff=only` — refuses non-fast-forward merges |
| **Transport security** | Rewrites `http://` to `https://`, enforces `http.sslVerify=true` | | **Transport security** | Rewrites `http://` to `https://`, enforces `http.sslVerify=true` |
| **Credential storage** | Platform-detected secure helper (`osxkeychain` on macOS, `libsecret` on Linux). Warns if using plaintext `store` | | **Credential storage** | Platform-detected secure helper (`osxkeychain` on macOS, `libsecret` on Linux). Warns if using plaintext `store` |
| **Credential hygiene** | Warns about plaintext `~/.git-credentials`, `~/.netrc`, `~/.npmrc` (tokens), `~/.pypirc` (passwords) |
| **Global gitignore** | Creates `~/.config/git/ignore` with patterns for secrets, credentials, and OS/IDE artifacts |
| **Defaults** | `init.defaultBranch=main` |
| **Forensic readiness** | Extended reflog retention (`gc.reflogExpire=180.days`, `gc.reflogExpireUnreachable=90.days`) |
| **Commit signing** | SSH-based signing with interactive key setup wizard (software or FIDO2 hardware key) | | **Commit signing** | SSH-based signing with interactive key setup wizard (software or FIDO2 hardware key) |
| **SSH hardening** | `StrictHostKeyChecking=accept-new`, `HashKnownHosts=yes`, `IdentitiesOnly=yes`, modern algorithm restrictions | | **SSH hardening** | `StrictHostKeyChecking=accept-new`, `HashKnownHosts=yes`, `IdentitiesOnly=yes`, modern algorithm restrictions |
| **SSH key hygiene** | Audits `~/.ssh/*.pub` for weak key types (DSA, ECDSA, short RSA) |
| **Visibility** | `log.showSignature=true` | | **Visibility** | `log.showSignature=true` |
A config backup is saved to `~/.config/git/pre-harden-backup-<timestamp>.txt` before any changes. A config backup is saved to `~/.config/git/pre-harden-backup-<timestamp>.txt` before any changes.
@@ -95,6 +102,7 @@ Options:
- Bash 3.2+ (compatible with macOS default bash) - Bash 3.2+ (compatible with macOS default bash)
Optional: Optional:
- `gitleaks` for pre-commit secret scanning (hook is installed regardless; scans run only if gitleaks is on `$PATH`)
- `ykman` or `fido2-token` for FIDO2 hardware key detection - `ykman` or `fido2-token` for FIDO2 hardware key detection
## Threat Model ## Threat Model
@@ -106,9 +114,11 @@ Optional:
- **Protocol downgrade** — blocks plaintext `git://` and dangerous `ext://` protocol - **Protocol downgrade** — blocks plaintext `git://` and dangerous `ext://` protocol
- **Hook-based RCE** — redirects hook execution away from repo-local `.git/hooks/` - **Hook-based RCE** — redirects hook execution away from repo-local `.git/hooks/`
- **Submodule attacks** — disables auto-recursion; submodules must be explicitly initialized - **Submodule attacks** — disables auto-recursion; submodules must be explicitly initialized
- **Credential theft** — ensures secure credential storage, warns about plaintext `store` - **Credential theft** — ensures secure credential storage, warns about plaintext `store`, detects leaked credentials in `~/.git-credentials`, `~/.netrc`, `~/.npmrc`, `~/.pypirc`
- **Secret leakage** — gitleaks pre-commit hook blocks commits containing secrets before they enter git history
- **Commit impersonation** — SSH signing proves key possession (anyone can fake `user.name`/`user.email`) - **Commit impersonation** — SSH signing proves key possession (anyone can fake `user.name`/`user.email`)
- **Filesystem tricks** — blocks NTFS/HFS+ path manipulation attacks - **Filesystem tricks** — blocks NTFS/HFS+/symlink path manipulation attacks
- **Weak SSH keys** — audits and warns about DSA, ECDSA, and short RSA keys
### What this does NOT protect against ### What this does NOT protect against
@@ -132,7 +142,7 @@ The script prints (but does not apply) server/org-level recommendations:
## Running Tests ## Running Tests
```bash ```bash
# Run the BATS test suite (64 tests) # Run the BATS test suite (90 tests)
./test/run.sh ./test/run.sh
# Requires bats-core submodules — init them if needed # Requires bats-core submodules — init them if needed

View File

@@ -0,0 +1,483 @@
# Git Security Hardening: A Practitioner's Reference
**The single most impactful thing an organization can do to harden its git security posture is enable push-time secret scanning.** GitGuardian's 2026 State of Secrets Sprawl report found **29 million new hardcoded secrets** on public GitHub in 2025 alone — a 34% year-over-year increase — with 64% of secrets from 2022 still unrevoked. Secret exposure remains the highest-likelihood, highest-impact attack surface because it requires zero sophistication to exploit: attackers scan public commits in real time, with a median time-to-discovery of **20 seconds** (Meli et al., NDSS 2019). Combined with branch protection enforcement, commit signing, and least-privilege token management, organizations can eliminate the most common git-related breach vectors with moderate effort.
This report covers the full git attack surface — from developer workstations through hosted platforms to CI/CD integration points — with platform-specific guidance for GitHub, GitLab, and Azure DevOps. Each section includes a threat model, numbered hardening checklist, real-world incident motivation, and residual risk assessment.
---
## Executive summary: ten highest-impact hardening measures
Ranked by risk reduction per unit of implementation effort for a typical 1050 developer organization:
1. **Enable push-time secret scanning** (GitHub Secret Protection or GitLab Ultimate push protection). Blocks the most frequently exploited vulnerability class before it enters the repository. ~1 hour to enable org-wide.
2. **Require pull request reviews on default and release branches** with dismissal of stale approvals. Prevents direct pushes of malicious or vulnerable code. ~30 minutes per repository, automatable via rulesets.
3. **Replace classic PATs with fine-grained, short-lived tokens** (GitHub fine-grained PATs, GitLab project tokens, or GitHub Apps). Eliminates the "keys to the kingdom" single-token failure mode that enabled the tj-actions and Trivy compromises. ~1 day for audit and migration.
4. **Enforce 2FA/MFA for all organization members.** Prevents account takeover — the root cause of the Gentoo GitHub compromise. ~1 hour to enable; budget 2 weeks for member compliance.
5. **Install a pre-commit hook stack** (gitleaks + framework) on all developer machines. Catches secrets before they enter git history, where removal is costly. ~2 hours via `core.hooksPath`.
6. **Pin GitHub Actions to full commit SHAs**, not version tags. Prevents supply-chain injection via mutable tags, as exploited in the tj-actions/changed-files and Trivy incidents. ~1 day for audit and update.
7. **Enable SSH commit signing with ed25519 keys.** Prevents commit author impersonation — the attack vector in the PHP git server compromise. ~1 hour per developer.
8. **Deploy a hardened `.gitconfig` template** org-wide (`fsckObjects`, `safe.bareRepository = explicit`, protocol restrictions). Blocks multiple client-side attack classes including CVE-2024-32002. ~30 minutes.
9. **Stream audit logs to a SIEM** (or at minimum, enable and review them weekly). Provides detection of privilege escalation, branch protection tampering, and anomalous clone activity. ~4 hours for initial setup.
10. **Restrict AI coding agent permissions** — enforce least-privilege tokens, prevent `--no-verify` bypasses, and require PR review for all AI-generated commits. Addresses the fastest-growing secret exposure vector (AI-assisted commits leak secrets at **2× the baseline rate**).
For organizations that can implement only five changes this quarter, start with items 1, 2, 3, 4, and 5.
---
## Table of contents
1. [Secret exposure](#1-secret-exposure)
2. [Authentication and access control](#2-authentication-and-access-control)
3. [Commit integrity](#3-commit-integrity)
4. [Branch protection and code review enforcement](#4-branch-protection-and-code-review-enforcement)
5. [Supply chain attacks via git](#5-supply-chain-attacks-via-git)
6. [Git hosting platform hardening](#6-git-hosting-platform-hardening)
7. [Developer workstation git security](#7-developer-workstation-git-security)
8. [Audit, monitoring, and incident response](#8-audit-monitoring-and-incident-response)
9. [Platform security feature comparison](#9-github-vs-gitlab-vs-azure-devops-security-feature-comparison)
10. [Appendix A: Minimal `.gitconfig` hardening template](#appendix-a-minimal-gitconfig-hardening-template)
11. [Appendix B: Pre-commit hook stack recommendation](#appendix-b-pre-commit-hook-stack-recommendation)
---
## 1. Secret exposure
### Threat model
**Who:** Any attacker scanning public repositories, or an insider with read access to private repos. **What:** Credentials, API keys, private keys, database connection strings committed to git history. **How:** Automated scanning of commits in real time (bots monitor the GitHub Events API), manual inspection after a breach, or scraping of repository history.
The scale of this problem is staggering. The landmark NDSS 2019 study by Meli et al. ("How Bad Can It Git?") found over **100,000 repositories** with leaked secrets, with a median of **1,793 unique new keys appearing per day** across public GitHub. Of these, **89% were genuinely sensitive** — not test keys. GitGuardian's 2026 report shows the problem is accelerating: **29 million new secrets** detected in 2025, and repositories using AI coding assistants exhibit a **6.4% secret leakage rate** versus a 1.5% baseline.
The Uber 2016 breach is the canonical cautionary tale: attackers found **hardcoded AWS access keys** in a private GitHub repository (accessed via credential-stuffed passwords on accounts without MFA). Those keys unlocked an S3 bucket containing 57 million user records. The result: a **$148 million FTC fine** and the criminal conviction of Uber's CISO for concealing the breach.
### Hardening checklist
**1. Enable push-time secret scanning on the hosting platform.** GitHub Secret Protection ($19/committer/month, now available on Team plans) blocks pushes containing detected secret patterns before they enter the repository. GitLab Ultimate provides equivalent push protection via server-side pre-receive hooks. Azure DevOps offers GitHub Advanced Security for Azure DevOps with similar capabilities. Push-time blocking is dramatically more effective than post-commit alerting — GitGuardian data shows **70% of secrets leaked in 2022 remained active** through 2025, indicating that alert-only approaches fail due to slow remediation.
**2. Deploy a pre-commit secret scanning tool** on all developer workstations (see Appendix B for detailed tool selection). This catches secrets before they reach the server, complementing platform-side scanning.
**3. Maintain comprehensive `.gitignore` patterns.** Every repository should exclude `.env`, `*.pem`, `*.key`, `*.p12`, `*.tfstate`, `credentials.json`, `service-account*.json`, `.npmrc`, `.pypirc`, and similar files. Use a global gitignore (`core.excludesfile`) for personal patterns plus repository-level `.gitignore` for project-specific ones.
**4. Understand and address the persistence problem.** Running `git rm secret.key && git commit` does **not** remove the secret from history. Git stores content as immutable blob objects; the original blob persists in packfiles and is cloned by every downstream user. **The primary remediation is always to rotate the secret immediately.** History rewriting is secondary cleanup. Use `git-filter-repo` (recommended by the Git project as the replacement for the deprecated `git filter-branch`) or BFG Repo-Cleaner. After rewriting, run `git reflog expire --expire=now --all && git gc --prune=now --aggressive`, force-push, and contact platform support to purge cached objects. Note that forks retain the pre-rewrite history, and all developers must delete and re-clone.
**5. Configure custom secret patterns** for organization-specific credential formats (internal API keys, database connection strings) beyond the default detection patterns provided by platforms.
### Residual risk
Push-time scanning catches only **known secret patterns**. Generic passwords, custom token formats, and secrets embedded in binary files evade pattern-based detection. The 2026 GitGuardian report notes that **58% of leaked credentials are "generic secrets"** that bypass standard detection. Defense-in-depth (pre-commit hooks + push protection + periodic full-history scans with TruffleHog's active verification) reduces but does not eliminate this residual risk. The operational cost is modest: pre-commit hooks add ~13 seconds to each commit, and occasional false positives require developer time to triage.
---
## 2. Authentication and access control
### Threat model
**Who:** External attackers via credential theft, phishing, or token leakage; insiders with excessive permissions. **What:** Unauthorized access to repositories, code modification, secret exfiltration. **How:** Compromised PATs, stolen SSH keys, OAuth token theft, or account takeover via password reuse.
The April 2022 Heroku/Travis CI OAuth token compromise demonstrated the blast radius of over-privileged tokens: stolen OAuth tokens from two integrators provided access to private repositories of dozens of organizations, including GitHub's own npm infrastructure. More recently, the March 2025 tj-actions/changed-files compromise (CVE-2025-30066) stemmed from a single compromised PAT belonging to a bot account — that one token affected **23,000+ downstream repositories**. The March 2026 Trivy supply-chain attack (CVE-2026-28353, CVSS 10.0) traced back to a single org-scoped PAT (`ORG_REPO_TOKEN`) used across 33 workflows; incomplete rotation after the first breach enabled a second, more devastating attack.
### Hardening checklist
**1. Use ed25519 SSH keys exclusively.** Ed25519 provides equivalent security to RSA-4096 with much smaller keys (68 vs. 544 characters), faster operations, and no parameter-selection pitfalls. Generate with `ssh-keygen -t ed25519 -C "user@company.com"`. GitHub blocks legacy RSA/SHA-1 signatures. For maximum security, use FIDO2 hardware-backed keys: `ssh-keygen -t ed25519-sk -O resident -O verify-required`.
**2. Replace classic PATs with fine-grained, scoped tokens.** GitHub classic PATs (`ghp_` prefix) with `repo` scope grant read/write access to **every repository the user can access** — a textbook violation of least privilege. GitHub fine-grained PATs (`github_pat_` prefix) scope to specific repositories with granular permissions and mandatory expiration. GitLab project/group access tokens provide similar scoping. Set maximum PAT lifetimes via org policy: 90 days for CI/CD, 30 days for one-off tasks.
**3. Prefer GitHub Apps over PATs for automation.** GitHub Apps generate **short-lived installation tokens** (1-hour expiry) with fine-grained, repository-scoped permissions — not tied to any human account. They survive employee departures without credential rotation and provide auditable "on behalf of" action trails. This is the single most effective control against the "over-privileged bot token" failure mode.
**4. Enforce SAML SSO and audit legacy credentials.** On GitHub Enterprise Cloud, PATs and SSH keys must be separately authorized for SSO after creation. Critical gap: on GitLab, project/group access tokens and deploy keys **bypass SSO enforcement entirely**. On Azure DevOps, PATs bypass device compliance and MFA requirements — only IP-fencing policies apply to non-interactive flows.
**5. Deploy SSH Certificate Authorities** (GitHub Enterprise Cloud). Certificates expire automatically (e.g., daily), eliminating key rotation as a manual process. CAs uploaded after March 2024 require certificate expiration dates.
**6. Implement IP allowlisting where feasible** (GitHub Enterprise Cloud, GitLab self-managed, Azure DevOps via Entra Conditional Access). Practical limitation: dynamic IPs for remote workers require VPN routing, and GitHub-hosted Actions runners have dynamic IPs — requiring self-hosted or larger runners with static IPs.
### Residual risk
SSO enforcement does not protect against all token types on all platforms. IP allowlisting is operationally expensive with distributed teams. Hardware security keys (FIDO2) provide the strongest authentication but introduce device-dependency risk. The operational cost of migrating from classic PATs to fine-grained PATs or GitHub Apps is moderate — budget 12 days for audit and migration per team.
---
## 3. Commit integrity
### Threat model
**Who:** Attackers with push access (via compromised credentials or server compromise) impersonating trusted developers. **What:** Forged commits attributed to maintainers, containing backdoors or malicious changes. **How:** Git allows arbitrary `user.name` and `user.email` configuration — without signing, anyone can commit as anyone.
The March 2021 PHP git server compromise is the definitive case study. Attackers pushed two commits to the official `php-src` repository on the self-hosted `git.php.net` server: one attributed to PHP creator Rasmus Lerdorf, another to core maintainer Nikita Popov. Both inserted a backdoor that would execute arbitrary PHP code via a specially crafted HTTP header. The commits had `Signed-off-by` trailers — but those are plain text, not cryptographic signatures. The PHP project subsequently migrated to GitHub and mandated 2FA.
### Hardening checklist
**1. Enable SSH commit signing org-wide** (recommended over GPG for simplicity). SSH signing, available since Git 2.34, uses keys developers already manage — no GPG keyring overhead. Configuration:
```
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
```
Maintain an `allowed_signers` file for local verification: `git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers`.
**2. Enable GitHub Vigilant Mode** (Settings → SSH and GPG keys → "Flag unsigned commits as unverified"). Without this, unsigned commits show no badge at all — making unsigned and forged commits visually indistinguishable from legitimate ones.
**3. Require signed commits via branch protection** on all default and release branches. GitHub supports this in both classic branch protection and rulesets. GitLab supports it via push rules. **Azure DevOps has no native commit signing verification** — signed commits are accepted but not validated or badged. Third-party pipeline decorators exist but are not equivalent.
**4. Understand what signing does and does not guarantee.** Signing proves **key possession at commit time** — it defeats impersonation and detects tampering. It does **not** prove the legitimate key owner was at the keyboard (a compromised machine with access to `ssh-agent` can produce valid signatures), and it does not prevent intentional malicious commits by a valid signer. Signing is an accountability control, not an authorization control.
**5. Monitor the SHA-1 to SHA-256 transition.** Git has used hardened SHA-1 (the `sha1collisiondetection` library) since Git 2.13.0, which detects known collision attack patterns with negligible false positive rates (< 2⁻⁹⁰). The SHA-1 chosen-prefix collision cost has fallen to approximately **$45,000** (Leurent & Peyrin, USENIX Security 2020, "SHA-1 is a Shambles"), and continues to decline with GPU advances. Git 3.0 (targeted for late 2026) will default new repositories to SHA-256, but **GitHub does not yet support SHA-256 repositories**, creating an ecosystem blocker. Practical risk today: low for most organizations due to the hardened SHA-1, but plan for migration.
### Residual risk
GitHub's squash-and-merge and rebase-and-merge operations create new unsigned commits, breaking signing chains. Sigstore/Gitsign offers keyless signing via OIDC identity, eliminating key management entirely, but GitHub does not yet display "Verified" badges for Gitsign signatures. For signing release artifacts (tarballs, binaries), signify or minisign are lightweight alternatives to GPG.
---
## 4. Branch protection and code review enforcement
### Threat model
**Who:** Malicious insiders, compromised accounts, or external attackers with any write access. **What:** Direct pushes of malicious code to production branches bypassing review. **How:** Pushing directly to unprotected branches, self-approving PRs, exploiting bypass vectors in branch protection configuration.
### Hardening checklist
**1. Require pull requests with at minimum one approving review** on default and release branches across all repositories. Use GitHub rulesets (preferred over classic branch protection — they aggregate restrictively, apply across fork networks, and support org-wide governance) or GitLab protected branches with "Allowed to push: No one."
**2. Enable "Dismiss stale approvals when new commits are pushed"** on all platforms. Without this, a developer can get approval, then push additional malicious commits and merge without re-review.
**3. Enable "Require approval of the most recent reviewable push"** (GitHub) or equivalent. This prevents a reviewer from pushing commits to a PR branch and then approving their own additions — a documented bypass vector identified by Legit Security that GitHub considers "working as expected."
**4. Check "Do not allow bypassing the above settings"** (GitHub) or restrict unprotect permissions (GitLab). By default, GitHub repository admins can bypass all branch protections. This single checkbox is the difference between "branch protection exists" and "branch protection is enforced."
**5. Use CODEOWNERS for security-critical paths** (authentication modules, cryptography, CI/CD configurations, the CODEOWNERS file itself). Protect the CODEOWNERS file with a self-referencing entry: `/.github/CODEOWNERS @security-team`. Validate that all listed code owners are active members with write permissions — invalid or departed users cause CODEOWNERS enforcement to fail silently.
**6. Require status checks before merging** (CI tests, SAST scans, secret scanning). Block merge unless all checks pass and branches are up to date with the target.
**7. Restrict force-push and branch deletion** on protected branches (default on GitHub and GitLab for protected branches, but verify explicitly).
### The "rubber stamp" problem
Branch protection is only as strong as the review process behind it. Research by Edmundson et al. (2013) found that **none of 30 developers** reviewing a web application found all seven known vulnerabilities, and more experience did not correlate with better detection. A 2024 study of OpenSSL and PHP code reviews found that even when security concerns were raised, **1820% went unfixed** due to disagreements. Code review becomes security theater when PRs are approved in under two minutes without comments, when reviewers lack security training, or when PR sizes exceed 400 lines.
Minimum viable security review: keep PRs under 400 lines, run automated SAST and secret scanning before human review, require conversation resolution before merge, and designate security-trained reviewers as CODEOWNERS for sensitive paths.
### Bypass vectors to monitor
Admin override (without "Do not allow bypassing"), GitHub Actions self-approval (a bot can use `GITHUB_TOKEN` to approve PRs — disable via org settings), PAT-based PR creation and approval (if a `repo`-scoped PAT exists in Actions secrets), and CODEOWNERS misconfiguration (invalid users, unprotected CODEOWNERS file, or the "Require review from Code Owners" toggle not enabled).
---
## 5. Supply chain attacks via git
### Threat model
**Who:** Nation-state actors (xz-utils pattern), opportunistic attackers (pwn requests), or automated bots. **What:** Injecting malicious code into software supply chains via git-hosted repositories, build systems, or CI/CD pipelines. **How:** Social engineering of maintainers, exploitation of CI workflow misconfigurations, fork-based object injection, or dependency confusion.
The **xz-utils backdoor** (CVE-2024-3094, CVSS 10.0) represents the most sophisticated git-related supply chain attack to date. An attacker using the identity "Jia Tan" spent **over two years** building trust in the xz-utils project through legitimate contributions, while sockpuppet accounts ("Jigar Kumar," "Dennis Ens") pressured the burned-out sole maintainer to grant commit access. The backdoor was injected via the build system (M4 macros in the release tarball, not visible in the git source), targeting SSH on Debian/Fedora x86_64 systems. Discovery was accidental: Microsoft engineer Andres Freund noticed SSH logins consuming 500ms instead of 100ms.
The **Codecov breach** (JanuaryApril 2021) demonstrated a different vector: attackers extracted a GCS credential from a Docker image layer, modified the Codecov bash uploader script to exfiltrate CI environment variables (including git credentials and tokens) from approximately **29,000 enterprise customers'** CI runners. A customer discovered the compromise by comparing the downloaded script's SHA-256 hash against the one on GitHub — they did not match.
### Hardening checklist
**1. Pin all GitHub Actions to full commit SHAs**, not version tags. Mutable tags are the primary attack vector in Actions supply-chain compromises: the tj-actions/changed-files attacker retroactively updated multiple version tags to reference malicious commits. Use `uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11` instead of `uses: actions/checkout@v4`.
**2. Set `GITHUB_TOKEN` permissions to read-only by default** at the organization level (Settings → Actions → General → Workflow permissions → Read repository contents and packages permissions). Grant write permissions explicitly per-workflow using the `permissions:` block.
**3. Never use `pull_request_target` with checkout of the PR head.** The `pull_request_target` trigger runs with full repository secrets and write tokens but can be tricked into executing attacker-controlled code from a fork PR. If `pull_request_target` is required, gate execution behind a maintainer-applied label (e.g., `safe-to-test`) and never check out `github.event.pull_request.head.sha`. The February 2026 "hackerbot-claw" campaign exploited exactly this misconfiguration to steal org-scoped PATs from the Trivy project.
**4. Restrict fork creation for sensitive repositories** and audit the fork network. GitHub forks share the underlying object store — data from deleted forks or private forks can remain accessible via commit SHA in the parent repository. This is documented behavior, not a bug. Run `trufflehog github-experimental --repo <URL> --object-discovery` to scan for cross-fork object reference (CFOR) exposures.
**5. Implement SLSA Level 2+ build provenance.** Use the `slsa-framework/slsa-github-generator` reusable workflow to generate signed provenance attestations for release artifacts. Verify with `slsa-verifier` or `gh attestation verify` in deployment pipelines. SLSA L3 (hardened, isolated builds) would have made the SolarWinds attack — where malware was injected at compile time by the SUNSPOT implant, not in the source repository — significantly harder.
**6. Run OpenSSF Scorecard** weekly on all repositories. The automated tool evaluates branch protection, dangerous workflow patterns (including `pull_request_target` misconfigurations), pinned dependencies, token permissions, and signed releases. Integrate via the `ossf/scorecard-action` GitHub Action.
**7. Review `.gitmodules` changes as high-risk in code review.** Submodule URL manipulation (including CVE-2023-29007, which allowed arbitrary git config injection via overlong submodule URLs) is an active attack vector. Prefer proper package managers over submodules where possible.
### Residual risk
The xz-utils pattern — years of social engineering culminating in build-system-only backdoors invisible in git source — is extremely difficult to defend against programmatically. Reproducible builds, multi-maintainer release signing, and comparing source against release tarballs are the best mitigations, but they require significant process maturity. The SLSA framework provides a graduated path toward build integrity.
---
## 6. Git hosting platform hardening
### Threat model
**Who:** External attackers targeting organization-wide misconfigurations; insiders exploiting overly permissive defaults. **What:** Mass data exfiltration, privilege escalation, or persistence via platform settings. **How:** Account takeover of admins (Gentoo pattern), exploitation of default-permissive organization settings, or abuse of integration permissions.
### Hardening checklist
**1. Enforce 2FA for all organization members.** Available on all GitHub tiers (Free, Team, Enterprise). The Gentoo GitHub compromise (June 2018) was enabled by a single admin account without 2FA — the attacker guessed the password using a predictable password scheme. Non-compliant members are removed (GitHub) or blocked (GitLab). Budget a 2-week compliance window.
**2. Set base member permissions to "No permission" or "Read"** rather than the default "Write." Grant additional permissions via teams.
**3. Restrict repository creation to organization owners** or specific teams. Prevent uncontrolled proliferation of repositories with inconsistent security settings.
**4. Disable forking of private repositories** unless specifically required. All forks of public repos are always public — this cannot be overridden.
**5. Set default repository visibility to "Private"** (or "Internal" on Enterprise) to prevent accidental public exposure.
**6. Enable audit log streaming to a SIEM** (see Section 8). GitHub Enterprise streams to Splunk, Datadog, Azure Sentinel, S3, and GCS. GitLab Ultimate streams to HTTP endpoints, S3, GCS, and Azure Event Hubs.
**7. Apply CIS Benchmarks** where available. The CIS GitHub Benchmark (v1.2.0) and CIS GitLab Benchmark (v1.0.1, with 125+ recommendations) provide audit-ready configuration checklists. GitLab provides an open-source CIS benchmark scanner CLI tool. No standalone CIS benchmark exists for Azure DevOps.
### Self-hosted versus cloud
Self-hosting (GitHub Enterprise Server, GitLab self-managed) provides network isolation and data sovereignty but **shifts the patching burden** to the organization. Recent GitLab self-managed CVEs underscore this risk: CVE-2025-25291/25292 (CVSS 8.8, SAML authentication bypass), CVE-2025-6948 (CVSS 8.7, CI/CD pipeline authorization bypass), and CVE-2025-0605 (2FA bypass via Git CLI). For organizations without dedicated security infrastructure teams — the typical Three Backticks Security client — **cloud-hosted platforms are usually more secure** because the vendor handles patching, and the latest security features ship immediately.
---
## 7. Developer workstation git security
### Threat model
**Who:** Attackers who can influence repository content (malicious hooks, crafted objects) or access developer machines. **What:** Code execution on developer machines via cloned repositories, credential theft from insecure storage. **How:** Malicious git hooks, crafted git objects exploiting parsing vulnerabilities, symlink attacks, or plaintext credential files.
**CVE-2024-32002** (Critical) demonstrated this risk: repositories with submodules could trick Git into executing a hook during clone, achieving remote code execution on Windows and macOS. The `git clone` command — seemingly safe — became an attack vector.
### Hardening checklist
**1. Use a secure credential helper.** Never use `git-credential-store` (stores credentials as plaintext in `~/.git-credentials`). Per-platform recommendations: **Windows:** Git Credential Manager (bundled with Git for Windows). **macOS:** `osxkeychain` or GCM. **Linux:** `libsecret` or GCM. For CI/CD, use environment variables or short-lived tokens.
**2. Deploy the hardened `.gitconfig` template** from Appendix A org-wide. Critical directives: `transfer.fsckObjects = true` (verifies integrity of all transferred objects, catching malformed or crafted objects), `safe.bareRepository = explicit` (prevents embedded bare repository attacks), `protocol.git.allow = never` (disables the unencrypted, unauthenticated `git://` protocol), and `core.hooksPath` pointing to a centralized, organization-managed hooks directory.
**3. Never set `safe.directory = *`.** This wildcard **completely disables** the ownership safety check introduced in Git 2.35.2 (CVE-2022-24765), allowing any user on a shared system to exploit a planted `.git` directory. Add specific trusted directories individually.
**4. Set `core.hooksPath` to a centralized, version-controlled hooks directory.** This overrides per-repository `.git/hooks/` directories, preventing execution of untrusted hooks from cloned repos. Distribute standardized hooks (pre-commit secret scanning, commit message validation) via this path.
**5. Enable path traversal protections**: `core.protectNTFS = true` and `core.protectHFS = true` even on Linux servers, to protect developers on mixed-OS teams from NTFS 8.3 short-name attacks (CVE-2019-1352) and HFS+ Unicode normalization attacks.
### AI coding agents require specific controls
AI coding agents are now a material attack surface. Claude Code alone accounts for **over 4% of all public GitHub commits** as of March 2026. GitGuardian reports AI-assisted commits leak secrets at **double the baseline rate** (~3.2% vs. ~1.5%). Key risks and mitigations:
- **Agents bypass pre-commit hooks.** Cursor and Cline are documented to append `--no-verify` to git commits. Mitigation: enforce secret scanning server-side (push protection), not just client-side hooks. Consider the `block-no-verify` package or Claude Code's `PreToolUse` hooks.
- **Agents with overprivileged tokens.** AI agents given broad PATs can be exploited via prompt injection in repository content (issues, README files, code comments, `.cursorrules` files). The IDEsaster research (December 2025) found **100% of tested AI coding tools** vulnerable to prompt injection, with CVE-2025-53773 (CVSS 9.6) affecting GitHub Copilot. Mitigation: scope agent tokens to read-only on specific repositories; use ephemeral, OAuth-scoped credentials.
- **Agents performing destructive operations.** Reports document Cursor running `git push --force-with-lease --no-verify` without permission. Mitigation: use Claude Code's auto mode (which blocks force-pushes via a classifier) or restrict agent git permissions to exclude force-push.
- **62% of AI-generated C programs contain vulnerabilities** (FormAI-v2 dataset, 2024). Treat all AI-generated code as untrusted — enforce the same review requirements as human-authored code.
---
## 8. Audit, monitoring, and incident response
### Threat model
**Who:** Any attacker who has gained access — detection depends on monitoring coverage. **What:** Detecting and responding to compromised accounts, unauthorized changes, and data exfiltration. **How:** Audit log analysis, anomaly detection, and git forensics.
### Hardening checklist
**1. Enable and stream audit logs.** GitHub Enterprise streams to Splunk, Datadog, Azure Sentinel, S3, and GCS. GitLab Ultimate streams structured JSON to HTTP endpoints, S3, GCS, and Azure Event Hubs. Azure DevOps streams to Log Analytics, Splunk, and Event Grid (90-day default retention; audit streaming must be explicitly enabled — it is off by default).
**2. Monitor these critical events**: branch protection rule changes (`protected_branch.destroy`), force-pushes to default branches, repository visibility changes to public, new deploy key creation, PAT creation followed by mass cloning (correlated detection), admin role self-assignment, webhook creation/modification, and SSO/2FA policy changes.
**3. Extend reflog retention for forensic readiness.** Default retention is 90 days (reachable commits) and 30 days (unreachable). Extend with:
```
git config --global gc.reflogExpire 180.days
git config --global gc.reflogExpireUnreachable 90.days
```
**4. Know the forensic toolkit for post-compromise investigation.** `git reflog --date=iso` shows every change to HEAD with timestamps. Force-push events appear as "forced-update" in `git reflog show origin/main`. `git fsck --unreachable --no-reflogs` finds truly orphaned commits that may contain evidence. **Disable garbage collection immediately** during an investigation: `git config gc.auto 0`.
**5. Integrate git events into existing SIEM detection rules.** Pre-built integrations exist for Microsoft Sentinel (sentinel4github), Datadog Cloud SIEM (native GitHub rules), Panther (Python detection-as-code), Elastic Security (git hook execution detection on Linux endpoints), and Google SecOps (YARA-L rules for GitHub audit logs).
### Residual risk
Audit logs capture platform events, not all git operations. A sophisticated attacker who clones a repository via an authorized token and exfiltrates code will appear as normal `git.clone` activity. Anomaly detection (e.g., mass cloning of multiple private repositories in a short window) is the primary detection mechanism for data exfiltration, but tuning thresholds to avoid alert fatigue requires operational investment.
---
## 9. GitHub vs GitLab vs Azure DevOps security feature comparison
| Security capability | GitHub | GitLab | Azure DevOps |
|---|---|---|---|
| **MFA enforcement** | Org/Enterprise (all tiers); blocks non-compliant members | Instance/Group (all tiers); grace period configurable | Via Entra Conditional Access (not native) |
| **SSO/SAML** | Enterprise Cloud only | All tiers (self-managed); group-level (SaaS) | Native via Entra ID |
| **Fine-grained tokens** | Fine-grained PATs + GitHub Apps | Project/Group tokens | Scoped PATs (no repo-level scoping) |
| **Branch protection** | Rulesets (org-wide) + classic rules | Protected branches + push rules (Premium+) | Branch policies with build validation |
| **Commit signing verification** | ✅ GPG + SSH + S/MIME badges | ✅ GPG + SSH badges | ❌ No native support |
| **Secret scanning** | 200+ types; push protection ($19/committer/mo) | Secret detection; push protection (Ultimate) | Via GHAzDO ($19/committer/mo) |
| **SAST (code scanning)** | CodeQL ($30/committer/mo) | Multiple engines (Ultimate, $99/user/mo) | CodeQL via GHAzDO ($30/committer/mo) |
| **DAST** | Third-party only | ✅ Native (Ultimate) | Third-party only |
| **Container scanning** | Third-party only | ✅ Native (Ultimate) | Third-party only |
| **Fuzz testing** | Third-party only | ✅ Web API fuzzing (Ultimate) | Third-party only |
| **Dependency scanning** | Dependabot (free for alerts) | Native (Ultimate) | Via GHAzDO |
| **Compliance frameworks** | Rulesets | ✅ Centralized frameworks (Ultimate) | Branch policies only |
| **Audit log streaming** | Enterprise only | Ultimate only | Log Analytics, Splunk, Event Grid |
| **IP allowlisting** | Enterprise Cloud | Self-managed (network level) | Entra Conditional Access |
| **CIS Benchmark** | ✅ v1.2.0 | ✅ v1.0.1 (with scanner tool) | ❌ None |
| **Self-hosted option** | Enterprise Server | Self-managed CE/EE | Azure DevOps Server |
| **Security pricing** | Base $21/user + GHAS $49/committer/mo | Ultimate $99/user/mo (all inclusive) | Free base + GHAzDO $49/committer/mo |
**Key takeaway for Three Backticks Security clients:** GitHub offers the most granular token management (fine-grained PATs, GitHub Apps) and the strongest commit signing ecosystem. GitLab Ultimate provides the broadest native scanner coverage (DAST, container scanning, fuzzing) at a simpler all-inclusive price point. Azure DevOps lags significantly in git-specific security — no commit signing verification, no native MFA enforcement, and limited audit capabilities compared to the other two platforms.
---
## Appendix A: Minimal `.gitconfig` hardening template
```ini
# ============================================================
# SECURITY-HARDENED ~/.gitconfig — Three Backticks Security
# Deploy org-wide via configuration management
# ============================================================
[user]
name = Your Name
email = your.email@company.com
# Prevent commits without explicit identity
useConfigOnly = true
[credential]
# OS-native secure storage (never use 'store')
# Windows: manager | macOS: osxkeychain | Linux: libsecret
helper = osxkeychain
[core]
# Centralized hooks — overrides per-repo .git/hooks/
hooksPath = ~/.config/git/hooks
# Path traversal protections (enable on ALL platforms)
protectNTFS = true
protectHFS = true
# Disable symlinks to prevent symlink-based attacks
symlinks = false
[transfer]
# Verify integrity of ALL transferred objects
fsckObjects = true
# Disable bundle URI fetching
bundleURI = false
[fetch]
fsckObjects = true
prune = true
[receive]
fsckObjects = true
[protocol]
version = 2
[protocol "file"]
# Restrict local file protocol (CVE-2022-39253)
allow = user
[protocol "git"]
# DISABLE unencrypted, unauthenticated git:// protocol
allow = never
[protocol "ext"]
# Disable external transport helpers
allow = never
[safe]
# Require explicit --git-dir for bare repos
bareRepository = explicit
# NEVER add: directory = *
[commit]
gpgsign = true
[tag]
gpgsign = true
[gpg]
# SSH signing (simpler than GPG)
format = ssh
[gpg "ssh"]
allowedSignersFile = ~/.config/git/allowed_signers
[push]
default = current
autoSetupRemote = true
[init]
templateDir = ~/.config/git/template
defaultBranch = main
[url "https://"]
# Force HTTPS for any git:// URLs
insteadOf = git://
```
---
## Appendix B: Pre-commit hook stack recommendation
### Recommended stack: gitleaks via the pre-commit framework
**Why gitleaks over alternatives:** Gitleaks (Go, MIT license, 160+ built-in detectors) is the best general-purpose pre-commit secret scanner for most organizations. It strikes the optimal balance: fast execution (Go binary, no runtime dependencies), low false-positive rate, extensible TOML configuration, and active maintenance. TruffleHog v3 is more comprehensive (800+ detectors, active API verification) but its AGPL license and heavier runtime make it better suited for CI pipeline scanning than developer-workstation pre-commit hooks. detect-secrets (Yelp) excels in legacy codebases with its baseline approach but requires Python. git-secrets (AWS Labs) is AWS-focused and showing reduced maintenance (272+ unresolved issues).
### Installation via pre-commit framework
```yaml
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
```
```bash
# Install pre-commit framework
pip install pre-commit
# Install hooks in the repository
pre-commit install
# Run against all files (first-time scan)
pre-commit run --all-files
```
### Organization-wide deployment via `core.hooksPath`
For enforcement beyond individual repositories:
```bash
# Create centralized hooks directory
mkdir -p ~/.config/git/hooks
# Create pre-commit hook that runs gitleaks
cat > ~/.config/git/hooks/pre-commit << 'EOF'
#!/bin/bash
# Org-wide pre-commit secret scanning
if command -v gitleaks &> /dev/null; then
gitleaks protect --staged --redact --verbose
if [ $? -ne 0 ]; then
echo "❌ Secret detected. Commit blocked."
echo "If false positive, use: SKIP=gitleaks git commit"
exit 1
fi
fi
EOF
chmod +x ~/.config/git/hooks/pre-commit
# Set globally
git config --global core.hooksPath ~/.config/git/hooks
```
### Complementary CI pipeline scanning
Add TruffleHog in CI for deeper scanning with active verification:
```yaml
# GitHub Actions
- name: TruffleHog scan
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verified --results=verified,unknown
```
### Tool selection matrix
| Criterion | gitleaks | TruffleHog v3 | detect-secrets | git-secrets |
|---|---|---|---|---|
| **Best role** | Pre-commit hook | CI pipeline scan | Legacy codebase onboarding | AWS-only environments |
| **Speed** | Fast (Go binary) | Moderate (verification adds latency) | Fast (diff-only) | Fast (bash/grep) |
| **False positive rate** | Low | Very low (verification) | Low (baseline filtering) | Moderate |
| **Maintenance status** | Active | Active | Active | Low maintenance |
| **License** | MIT | AGPL 3.0 | Apache 2.0 | Apache 2.0 |
| **Operational cost** | ~12s per commit | ~1030s per scan | ~1s per commit | ~1s per commit |
The recommended stack for a Three Backticks Security client is: **gitleaks as pre-commit hook** (developer workstation) + **TruffleHog v3 in CI** (verified scanning) + **platform push protection** (server-side enforcement). This three-layer approach provides defense-in-depth against the most common and damaging class of git security vulnerabilities.
---
*Prepared by Three Backticks Security. This document reflects the state of git security tooling and platform features as of March 2026. Platform features, pricing, and tool versions should be verified against current vendor documentation before implementation.*

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,330 @@
# git-harden.sh v0.2.0 — Expanded Hardening Features
## Motivation
Gap analysis of two independent research reports (Claude Opus 4.6 and Gemini 3.1 Pro, March 2026) against the v0.1.0 script identified six feature areas where the script falls short of current best-practice recommendations. All additions follow the existing audit+apply pattern and require no new CLI flags.
### Source Reports
- `docs/research/Claude Opus 4.6 report.md`
- `docs/research/Gemini 3.1 Pro report.md`
## Scope
All changes are additive — no existing behavior changes. The v0.2.0 script will:
1. Install a gitleaks pre-commit hook
2. Create and configure a global gitignore
3. Detect plaintext credential files
4. Audit SSH key hygiene
5. Add 8 new git config settings
6. Detect dangerous `safe.directory = *` wildcard
Version bump: `0.1.0``0.2.0`.
---
## Feature 1: Pre-commit Hook Installation (gitleaks)
### Background
Both reports rank pre-commit secret scanning as the single most impactful workstation-level defense. The v0.1.0 script sets `core.hooksPath = ~/.config/git/hooks` but installs no hooks, leaving the directory empty.
### Audit Behavior
- Check if `~/.config/git/hooks/pre-commit` exists and is executable.
- If it exists, check whether it contains a `gitleaks` invocation (grep for `gitleaks`).
- `[OK]` if pre-commit hook exists and references gitleaks.
- `[WARN]` if pre-commit hook exists but does NOT reference gitleaks (user-managed hook — don't touch).
- `[MISS]` if no pre-commit hook exists.
### Apply Behavior
- Check if `gitleaks` is on `$PATH` via `command -v gitleaks`.
- If gitleaks is found and no pre-commit hook exists:
- Create `~/.config/git/hooks/pre-commit` with the following content:
```bash
#!/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
```
- `chmod +x` the hook.
- If gitleaks is NOT found:
- `[WARN]` with install instructions:
- macOS: `brew install gitleaks`
- Linux: `brew install gitleaks` or download from GitHub releases
- Still create the hook script (it guards with `command -v` so it's safe without gitleaks installed). Prompt the user before creating.
- If a pre-commit hook already exists (any content): warn and skip. Do not overwrite user-managed hooks.
### Bypass Mechanism
The `SKIP_GITLEAKS=1` environment variable allows a single commit to bypass the hook without `--no-verify` (which skips ALL hooks). This is documented in the hook script itself.
### Acceptance Criteria
- `--audit` reports status of pre-commit hook with gitleaks.
- Apply creates a working hook that blocks commits containing secrets.
- Existing user hooks are never overwritten.
- Hook is safe to install even if gitleaks is not yet installed.
---
## Feature 2: Global Gitignore
### Background
Both reports stress maintaining comprehensive `.gitignore` patterns for secrets. No amount of scanning catches what was never tracked in the first place.
### Audit Behavior
- Check if `core.excludesFile` is set in global git config.
- If not set: `[MISS]`.
- If set: check whether the referenced file contains key security patterns (`.env`, `*.pem`, `*.key`).
- `[OK]` if file exists and contains at least one security pattern.
- `[WARN]` if file exists but lacks security patterns: "Global gitignore at <path> lacks secret patterns (.env, *.pem, *.key). Consider adding them."
### Apply Behavior
- If `core.excludesFile` is not set:
- Create `~/.config/git/ignore` with the following patterns:
```gitignore
# === Security: secrets & credentials ===
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
*.jks
credentials.json
service-account*.json
.git-credentials
.netrc
.npmrc
.pypirc
# === Security: Terraform state (contains secrets) ===
*.tfstate
*.tfstate.backup
# === OS artifacts ===
.DS_Store
Thumbs.db
Desktop.ini
# === IDE artifacts ===
.idea/
.vscode/
*.swp
*.swo
*~
```
- Set `core.excludesFile = ~/.config/git/ignore` via `apply_git_setting`.
- If `core.excludesFile` is already set to a different path:
- Print `[INFO]` noting the existing path. Do not modify or overwrite.
- If the file lacks security patterns, print `[WARN]` with the missing patterns (informational only — no auto-append).
### Acceptance Criteria
- Audit reports whether `core.excludesFile` is configured.
- Audit checks existing gitignore files for security pattern coverage and warns if missing.
- Apply creates the file and sets the config only when nothing is configured.
- Existing configurations are never modified — warnings are informational.
- The `!.env.example` negation allows committing example env files.
---
## Feature 3: Plaintext Credential File Detection
### Background
Both reports warn that `git-credential-store` writes passwords to `~/.git-credentials` in plaintext. The Gemini report additionally calls out infostealer malware targeting this file. Adjacent credential files (.netrc, .npmrc, .pypirc) pose similar risks.
### Audit Behavior (audit-only, no apply action)
Check for existence and content of these files:
| File | Detection | Severity |
|------|-----------|----------|
| `~/.git-credentials` | File exists | `[WARN]` — "Plaintext git credentials. Migrate to credential helper (osxkeychain/libsecret) and delete this file." |
| `~/.netrc` | File exists | `[WARN]` — "Plaintext network credentials found. May contain git hosting tokens." |
| `~/.npmrc` | File contains `_authToken=` followed by a non-empty value (regex: `_authToken=.+`) | `[WARN]` — "npm registry token in plaintext. Use `npm config set` with env vars instead." |
| `~/.pypirc` | File contains `password` | `[WARN]` — "PyPI credentials in plaintext. Use keyring or token-based auth instead." |
### Apply Behavior
None. The script does not delete or modify user credential files. Warnings are informational only.
### Section Placement
New section: "Credential Hygiene" — placed after the existing "Credential Storage" audit.
### Acceptance Criteria
- Each detected file produces a specific, actionable warning.
- No false positives on files that don't contain credentials (e.g., .npmrc with only registry URL, no token).
- No files are modified or deleted.
---
## Feature 4: SSH Key Hygiene Audit
### Background
Both reports recommend ed25519 exclusively. The Claude report notes GitHub blocks legacy RSA/SHA-1 signatures. The Gemini report recommends banning DSA and ECDSA.
### Audit Behavior (audit-only, no apply action)
- Scan `~/.ssh/*.pub` files.
- Additionally, parse `IdentityFile` directives from `~/.ssh/config` (the v0.1.0 script already has this parsing logic in `detect_existing_keys`) and include any referenced `.pub` files not already covered by the glob.
- For each `.pub` file, read the first field to determine key type.
- Use `ssh-keygen -l -f <file>` to extract bit length for RSA keys.
- Report:
| Key Type | Verdict |
|----------|---------|
| `ssh-ed25519` | `[OK]` |
| `sk-ssh-ed25519@openssh.com` | `[OK]` |
| `ssh-rsa` with >= 2048 bits | `[WARN]` — "RSA key (%d bits). Consider migrating to ed25519." |
| `ssh-rsa` with < 2048 bits | `[WARN]` — "Weak RSA key (%d bits). Migrate to ed25519 immediately." |
| `ssh-dss` | `[WARN]` — "DSA key (deprecated). Migrate to ed25519." |
| `ecdsa-sha2-*` | `[WARN]` — "ECDSA key. Consider migrating to ed25519." |
| `sk-ecdsa-sha2-*` | `[OK]` — Hardware-backed ECDSA is acceptable. |
### Apply Behavior
None. Key migration is too risky to automate. Warnings are informational.
### Section Placement
New section: "SSH Key Hygiene" — placed after the existing "SSH Configuration" audit.
### Acceptance Criteria
- All `.pub` files in `~/.ssh/` are scanned and classified.
- RSA bit length is correctly extracted.
- No false warnings on ed25519 or ed25519-sk keys.
- No keys are modified or deleted.
---
## Feature 5: Additional Git Config Settings
### Background
Eight new settings recommended by one or both reports that the v0.1.0 script does not audit or apply.
### Settings
| Setting | Value | Rationale | Report Source | Section |
|---------|-------|-----------|---------------|---------|
| `user.useConfigOnly` | `true` | Prevent commits without explicit identity — forces user.name/email to be set, blocking accidental commits under system defaults | Claude | New: "Identity" |
| `gc.reflogExpire` | `180.days` | Extend reflog retention for forensic readiness (default 90 days) | Claude | New: "Forensic Readiness" |
| `gc.reflogExpireUnreachable` | `90.days` | Extend unreachable reflog retention (default 30 days) | Claude | New: "Forensic Readiness" |
| `transfer.bundleURI` | `false` | Disable bundle URI fetching — reduces attack surface | Claude | Existing: "Object Integrity" |
| `protocol.version` | `2` | Wire protocol v2 — better performance, reduced attack surface from reference advertisements | Gemini | Existing: "Protocol Restrictions" |
| `init.defaultBranch` | `main` | Modern default branch name | Claude | New: "Defaults" |
| `core.symlinks` | `false` | Prevent symlink-based attacks (relevant to CVE-2024-32002). **Interactive-only**: prompt with default=yes, but skip in `-y` mode (may break symlink-dependent workflows like Node.js monorepos) | Claude | Existing: "Filesystem Protection" |
| `fetch.prune` | `true` | Auto-prune stale remote-tracking refs on fetch | Claude | Existing: "Object Integrity" |
### Audit & Apply Behavior
Seven of eight follow the existing `audit_git_setting` / `apply_git_setting` pattern. `core.symlinks` is the exception:
- **Audit**: reports current value like all other settings.
- **Interactive mode**: prompts with default=yes ("Disable symlinks to prevent symlink-based attacks (CVE-2024-32002)? Note: this may break projects that use symlinks, e.g. Node.js monorepos. [Y/n]").
- **`-y` mode**: skips `core.symlinks` entirely (does not auto-apply). This is because disabling symlinks can silently break real workflows, and `-y` mode should not cause unexpected breakage.
### Acceptance Criteria
- All 8 settings appear in audit output under their respective sections.
- All 8 settings are applied (with prompt or auto) in apply mode.
- Existing tests updated to cover new settings.
---
## Feature 6: `safe.directory` Wildcard Detection
### Background
The Claude report explicitly warns: "Never set `safe.directory = *`." This wildcard completely disables the ownership safety check introduced in Git 2.35.2 (CVE-2022-24765), allowing any user on a shared system to exploit a planted `.git` directory.
### Audit Behavior
- Run `git config --global --get-all safe.directory` and check if any value is `*`.
- `[WARN]` if `*` is found: "safe.directory = * disables ownership checks (CVE-2022-24765). Remove this setting."
- No output if `*` is not found (this is not a setting we apply — absence of `*` is the correct state).
### Apply Behavior
- If `*` is detected, prompt the user: "Remove dangerous safe.directory = * setting?"
- If accepted, run `git config --global --unset safe.directory '*'` (note: must handle the case where multiple values exist — use `--unset-all` if needed, but only for the `*` value).
### Section Placement
Added to existing "Repository Safety" section.
### Acceptance Criteria
- Wildcard detected and warned about in audit mode.
- Apply mode offers to remove it.
- Non-wildcard `safe.directory` entries are not affected.
---
## Audit Section Order (v0.2.0)
Updated ordering with new sections integrated:
1. Identity (`user.useConfigOnly`)
2. Object Integrity (existing + `transfer.bundleURI`, `fetch.prune`)
3. Protocol Restrictions (existing + `protocol.version`)
4. Filesystem Protection (existing + `core.symlinks`)
5. Hook Control (existing)
6. **Pre-commit Hook** (new — gitleaks)
7. Repository Safety (existing + `safe.directory` wildcard detection)
8. Pull/Merge Hardening (existing)
9. Transport Security (existing)
10. Credential Storage (existing)
11. **Credential Hygiene** (new — plaintext file detection)
12. **Global Gitignore** (new)
13. **Defaults** (new — `init.defaultBranch`)
14. **Forensic Readiness** (new — reflog retention)
15. Visibility (existing)
16. Signing Configuration (existing)
17. SSH Configuration (existing)
18. **SSH Key Hygiene** (new)
---
## Non-Goals
- Package manager integration (no `brew install` or `apt install`).
- Modifying or deleting user files (credential files, SSH keys).
- Repository-level hardening (branch protection, CODEOWNERS — these remain in admin recommendations).
- CI/CD pipeline configuration.
- GPG signing support (the script remains SSH-signing focused).
## Compatibility
Same as v0.1.0: Bash 3.2+, macOS and Linux. No new dependencies. Gitleaks is optional — the hook is safe without it.
## Testing
- Extend existing BATS test suite to cover all new audit checks and apply actions.
- Add container test cases for gitleaks hook installation (with and without gitleaks present).
- Test `safe.directory = *` detection and removal.
- Test credential file detection with mock files.
- Test SSH key hygiene with various key types.

View File

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

View File

@@ -515,6 +515,37 @@ SSHEOF
grep -q "HashKnownHosts no" "${TEST_HOME}/.ssh/config" grep -q "HashKnownHosts no" "${TEST_HOME}/.ssh/config"
} }
@test "audit recognises SSH directives using = separator" {
source_functions
cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF'
StrictHostKeyChecking=accept-new
HashKnownHosts = yes
SSHEOF
run audit_ssh_directive "StrictHostKeyChecking" "accept-new"
assert_output --partial "[OK]"
run audit_ssh_directive "HashKnownHosts" "yes"
assert_output --partial "[OK]"
}
@test "apply skips SSH directives using = separator when value matches" {
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 ]
}
# =========================================================================== # ===========================================================================
# Signing: key detection # Signing: key detection
# =========================================================================== # ===========================================================================
@@ -820,3 +851,302 @@ SSHEOF
assert_output --partial "branch protection" assert_output --partial "branch protection"
assert_output --partial "vigilant mode" assert_output --partial "vigilant mode"
} }
# ===========================================================================
# v0.2.0: New git config settings
# ===========================================================================
@test "audit reports new v0.2.0 settings as MISS on fresh config" {
source_functions
detect_platform
detect_credential_helper
run audit_git_config
assert_output --partial "user.useConfigOnly"
assert_output --partial "transfer.bundleURI"
assert_output --partial "fetch.prune"
assert_output --partial "protocol.version"
assert_output --partial "init.defaultBranch"
assert_output --partial "gc.reflogExpire"
assert_output --partial "gc.reflogExpireUnreachable"
assert_output --partial "core.symlinks"
}
@test "-y mode applies new v0.2.0 settings" {
run bash "$SCRIPT" -y
assert_success
[ "$(git config --global user.useConfigOnly)" = "true" ]
[ "$(git config --global transfer.bundleURI)" = "false" ]
[ "$(git config --global fetch.prune)" = "true" ]
[ "$(git config --global protocol.version)" = "2" ]
[ "$(git config --global init.defaultBranch)" = "main" ]
[ "$(git config --global gc.reflogExpire)" = "180.days" ]
[ "$(git config --global gc.reflogExpireUnreachable)" = "90.days" ]
}
@test "-y mode does NOT apply core.symlinks" {
run bash "$SCRIPT" -y
assert_success
local symlinks
symlinks="$(git config --global --get core.symlinks 2>/dev/null || echo "unset")"
[ "$symlinks" = "unset" ]
}
# ===========================================================================
# v0.2.0: safe.directory wildcard detection
# ===========================================================================
@test "audit detects safe.directory = * wildcard" {
source_functions
detect_platform
detect_credential_helper
git config --global safe.directory '*'
run audit_git_config
assert_output --partial "safe.directory = * disables ownership checks"
}
@test "audit does not warn without safe.directory wildcard" {
source_functions
detect_platform
detect_credential_helper
git config --global safe.directory "/some/path"
run audit_git_config
refute_output --partial "safe.directory = * disables"
}
@test "-y mode removes safe.directory = * wildcard" {
git config --global safe.directory '*'
run bash "$SCRIPT" -y
assert_success
local safe_dirs
safe_dirs="$(git config --global --get-all safe.directory 2>/dev/null || echo "none")"
refute [ "$safe_dirs" = "*" ]
}
# ===========================================================================
# v0.2.0: Pre-commit hook
# ===========================================================================
@test "audit reports MISS when no pre-commit hook exists" {
source_functions
run audit_precommit_hook
assert_output --partial "No pre-commit hook"
}
@test "audit reports OK when gitleaks hook exists" {
source_functions
mkdir -p "${HOME}/.config/git/hooks"
cat > "${HOME}/.config/git/hooks/pre-commit" << 'EOF'
#!/usr/bin/env bash
gitleaks protect --staged
EOF
chmod +x "${HOME}/.config/git/hooks/pre-commit"
run audit_precommit_hook
assert_output --partial "[OK]"
assert_output --partial "gitleaks"
}
@test "audit reports WARN for non-gitleaks hook" {
source_functions
mkdir -p "${HOME}/.config/git/hooks"
printf '#!/usr/bin/env bash\necho custom hook\n' > "${HOME}/.config/git/hooks/pre-commit"
chmod +x "${HOME}/.config/git/hooks/pre-commit"
run audit_precommit_hook
assert_output --partial "does not reference gitleaks"
}
@test "apply does not overwrite existing pre-commit hook" {
source_functions
AUTO_YES=true
mkdir -p "${HOME}/.config/git/hooks"
printf '#!/usr/bin/env bash\necho my hook\n' > "${HOME}/.config/git/hooks/pre-commit"
run apply_precommit_hook
assert_output --partial "not overwriting"
# Verify original content preserved
run cat "${HOME}/.config/git/hooks/pre-commit"
assert_output --partial "my hook"
}
# ===========================================================================
# v0.2.0: Global gitignore
# ===========================================================================
@test "audit reports MISS when no excludesFile configured" {
source_functions
run audit_global_gitignore
assert_output --partial "no global gitignore configured"
}
@test "audit reports OK when excludesFile has security patterns" {
source_functions
mkdir -p "${HOME}/.config/git"
printf '.env\n*.pem\n*.key\n' > "${HOME}/.config/git/ignore"
git config --global core.excludesFile "~/.config/git/ignore"
run audit_global_gitignore
assert_output --partial "[OK]"
assert_output --partial "contains security patterns"
}
@test "audit warns when excludesFile lacks security patterns" {
source_functions
mkdir -p "${HOME}/.config/git"
printf '*.log\n*.tmp\n' > "${HOME}/.config/git/ignore"
git config --global core.excludesFile "~/.config/git/ignore"
run audit_global_gitignore
assert_output --partial "lacks secret patterns"
}
@test "-y mode creates global gitignore" {
run bash "$SCRIPT" -y
assert_success
[ -f "${HOME}/.config/git/ignore" ]
run cat "${HOME}/.config/git/ignore"
assert_output --partial ".env"
assert_output --partial "*.pem"
assert_output --partial "*.key"
assert_output --partial "!.env.example"
[ "$(git config --global core.excludesFile)" = "~/.config/git/ignore" ]
}
@test "-y mode skips gitignore when excludesFile already set" {
git config --global core.excludesFile "/some/other/path"
run bash "$SCRIPT" -y
assert_success
[ "$(git config --global core.excludesFile)" = "/some/other/path" ]
}
# ===========================================================================
# v0.2.0: Credential hygiene
# ===========================================================================
@test "audit warns about ~/.git-credentials" {
source_functions
printf 'https://user:token@github.com\n' > "${HOME}/.git-credentials"
run audit_credential_hygiene
assert_output --partial "git-credentials"
assert_output --partial "plaintext"
}
@test "audit warns about ~/.netrc" {
source_functions
printf 'machine github.com\nlogin user\npassword token\n' > "${HOME}/.netrc"
run audit_credential_hygiene
assert_output --partial ".netrc"
}
@test "audit warns about ~/.npmrc with auth token" {
source_functions
printf '//registry.npmjs.org/:_authToken=npm_abcdef123456\n' > "${HOME}/.npmrc"
run audit_credential_hygiene
assert_output --partial "npm registry token"
}
@test "audit does not warn about ~/.npmrc without token" {
source_functions
printf 'registry=https://registry.npmjs.org/\n' > "${HOME}/.npmrc"
run audit_credential_hygiene
refute_output --partial "npm registry token"
}
@test "audit warns about ~/.pypirc with password" {
source_functions
printf '[pypi]\nusername = user\npassword = secret123\n' > "${HOME}/.pypirc"
run audit_credential_hygiene
assert_output --partial "PyPI credentials"
}
@test "audit no warnings with clean credential state" {
source_functions
run audit_credential_hygiene
refute_output --partial "[WARN]"
}
# ===========================================================================
# v0.2.0: SSH key hygiene
# ===========================================================================
@test "SSH key hygiene: ed25519 reported as OK" {
source_functions
ssh-keygen -t ed25519 -f "${HOME}/.ssh/test_ed25519" -N "" -q
run audit_ssh_key_hygiene
assert_output --partial "[OK]"
assert_output --partial "ed25519"
}
@test "SSH key hygiene: RSA key reported as WARN" {
source_functions
ssh-keygen -t rsa -b 2048 -f "${HOME}/.ssh/test_rsa" -N "" -q
run audit_ssh_key_hygiene
assert_output --partial "[WARN]"
assert_output --partial "RSA"
assert_output --partial "migrating to ed25519"
}
@test "SSH key hygiene: no keys produces info message" {
source_functions
# Remove the default keys created in setup (there are none)
rm -f "${HOME}/.ssh/"*.pub
run audit_ssh_key_hygiene
assert_output --partial "No SSH public keys found"
}
@test "SSH key hygiene: picks up keys from IdentityFile in ssh config" {
source_functions
mkdir -p "${HOME}/.ssh/custom"
ssh-keygen -t ed25519 -f "${HOME}/.ssh/custom/my_key" -N "" -q
printf 'IdentityFile ~/.ssh/custom/my_key\n' > "${HOME}/.ssh/config"
run audit_ssh_key_hygiene
assert_output --partial "[OK]"
assert_output --partial "my_key"
}
# ===========================================================================
# v0.2.0: Version bump
# ===========================================================================
@test "--version reports 0.2.0" {
run bash "$SCRIPT" --version
assert_output --partial "0.2.0"
}

View File

@@ -6,7 +6,7 @@ set -o nounset
set -o pipefail set -o pipefail
IFS=$'\n\t' IFS=$'\n\t'
readonly TMUX_SESSION="test" TMUX_SESSION="test-$$"
readonly SCRIPT_PATH="${HOME}/git-harden.sh" readonly SCRIPT_PATH="${HOME}/git-harden.sh"
# Colors # Colors
@@ -43,10 +43,24 @@ send() {
tmux send-keys -t "$TMUX_SESSION" "$@" tmux send-keys -t "$TMUX_SESSION" "$@"
} }
# Start git-harden.sh in a tmux session # Start git-harden.sh in a tmux session.
# Explicitly pass HOME and GIT_CONFIG_GLOBAL — tmux spawns a login shell
# which resets HOME from the passwd entry, breaking the isolated test env.
start_session() { start_session() {
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true
tmux new-session -d -s "$TMUX_SESSION" "bash ${SCRIPT_PATH}" sleep 0.5
tmux new-session -d -s "$TMUX_SESSION" \
"export HOME='${HOME}'; export GIT_CONFIG_GLOBAL='${GIT_CONFIG_GLOBAL:-}'; bash '${SCRIPT_PATH}'"
# Keep the pane alive after the script exits so capture_output can read it
tmux set-option -t "$TMUX_SESSION" remain-on-exit on
sleep 0.5
# Verify session started
if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
printf 'ERROR: tmux session "%s" failed to start\n' "$TMUX_SESSION" >&2
printf 'SCRIPT_PATH=%s\n' "$SCRIPT_PATH" >&2
printf 'HOME=%s\n' "$HOME" >&2
return 1
fi
} }
# Wait for the script to exit and capture final output # Wait for the script to exit and capture final output

View File

@@ -26,9 +26,11 @@ main() {
wait_for "Proceed with hardening" wait_for "Proceed with hardening"
send "y" Enter send "y" Enter
# Accept each setting prompt by sending "y" + Enter repeatedly # Accept each setting prompt by sending "y" + Enter repeatedly.
# v0.2.0 adds more prompts (pre-commit hook, gitignore, core.symlinks),
# so we need enough iterations to get through all of them.
local pane_content local pane_content
for _ in $(seq 1 30); do for _ in $(seq 1 50); do
sleep 0.3 sleep 0.3
pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)" pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)"
if printf '%s' "$pane_content" | grep -qF "Signing key options"; then if printf '%s' "$pane_content" | grep -qF "Signing key options"; then
@@ -41,7 +43,7 @@ main() {
done done
# Signing wizard — skip # Signing wizard — skip
wait_for "Signing key options" 15 wait_for "Signing key options" 20
send "s" Enter send "s" Enter
# Wait for completion # Wait for completion

View File

@@ -29,9 +29,9 @@ main() {
wait_for "Proceed with hardening" wait_for "Proceed with hardening"
send "y" Enter send "y" Enter
# Accept settings until signing wizard # Accept settings until signing wizard (v0.2.0 adds more prompts)
local pane_content local pane_content
for _ in $(seq 1 30); do for _ in $(seq 1 50); do
sleep 0.3 sleep 0.3
pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)" pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)"
if printf '%s' "$pane_content" | grep -qF "Signing key options"; then if printf '%s' "$pane_content" | grep -qF "Signing key options"; then
@@ -44,7 +44,7 @@ main() {
done done
# Signing wizard — option 1: generate ed25519 # Signing wizard — option 1: generate ed25519
wait_for "Signing key options" 15 wait_for "Signing key options" 20
send "1" Enter send "1" Enter
# ssh-keygen prompts for passphrase — enter empty twice # ssh-keygen prompts for passphrase — enter empty twice
@@ -53,6 +53,10 @@ main() {
wait_for "Enter same passphrase" 10 wait_for "Enter same passphrase" 10
send "" Enter send "" Enter
# Signing wizard asks "Enable commit and tag signing?" — accept
wait_for "Enable commit and tag signing" 10
send "y" Enter
# Wait for completion # Wait for completion
sleep 3 sleep 3
capture_output >/dev/null 2>&1 || true capture_output >/dev/null 2>&1 || true

View File

@@ -16,6 +16,12 @@ main() {
printf 'Test: Signing wizard - skip\n' >&2 printf 'Test: Signing wizard - skip\n' >&2
# Remove any keys from prior tests so wizard shows key generation options
rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub"
rm -f "${HOME}/.ssh/id_ed25519_sk" "${HOME}/.ssh/id_ed25519_sk.pub"
git config --global --unset user.signingkey 2>/dev/null || true
git config --global --unset commit.gpgsign 2>/dev/null || true
start_session start_session
# Safety review gate # Safety review gate
@@ -26,9 +32,9 @@ main() {
wait_for "Proceed with hardening" wait_for "Proceed with hardening"
send "y" Enter send "y" Enter
# Accept settings until signing wizard # Accept settings until signing wizard (v0.2.0 adds more prompts)
local pane_content local pane_content
for _ in $(seq 1 30); do for _ in $(seq 1 50); do
sleep 0.3 sleep 0.3
pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)" pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)"
if printf '%s' "$pane_content" | grep -qF "Signing key options"; then if printf '%s' "$pane_content" | grep -qF "Signing key options"; then
@@ -41,7 +47,7 @@ main() {
done done
# Signing wizard — skip # Signing wizard — skip
wait_for "Signing key options" 15 wait_for "Signing key options" 20
send "s" Enter send "s" Enter
# Wait for completion # Wait for completion