Skip to content

Commit 032685f

Browse files
author
Patrick Lewis
committed
feat: add pre-commit hooks for secret scanning and shell script security checks
1 parent e2634db commit 032685f

3 files changed

Lines changed: 280 additions & 0 deletions

File tree

‎.githooks/pre-commit‎

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
#!/bin/bash
2+
# =============================================================================
3+
# pre-commit
4+
#
5+
# Pre-commit hook that enforces secret scanning and shell script security
6+
# checks on staged files before allowing a commit.
7+
#
8+
# Checks (in order):
9+
# 1. Secret scanning — gitleaks (if installed) or built-in regex patterns
10+
# 2. Shell linting — shellcheck on staged .sh files (if installed)
11+
#
12+
# Install:
13+
# git config core.hooksPath .githooks
14+
# (or run: ./install-hooks.sh)
15+
#
16+
# Bypass (emergency only):
17+
# git commit --no-verify
18+
#
19+
# Requirements:
20+
# - bash 4+
21+
# - gitleaks (recommended — brew install gitleaks)
22+
# - shellcheck (recommended — brew install shellcheck)
23+
# =============================================================================
24+
25+
set -euo pipefail
26+
27+
###
28+
## COLOR CODES
29+
###
30+
RED='\033[0;31m'
31+
GREEN='\033[0;32m'
32+
YELLOW='\033[1;33m'
33+
BLUE='\033[0;34m'
34+
NC='\033[0m'
35+
36+
print_status() { echo -e "${BLUE}[PRE-COMMIT]${NC} $1"; }
37+
print_success() { echo -e "${GREEN}[PRE-COMMIT]${NC} $1"; }
38+
print_warning() { echo -e "${YELLOW}[PRE-COMMIT]${NC} $1"; }
39+
print_error() { echo -e "${RED}[PRE-COMMIT]${NC} $1" >&2; }
40+
41+
HOOK_FAILED=0
42+
43+
###
44+
## Staged file list (new, copied, or modified — excludes deleted files)
45+
###
46+
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)
47+
48+
if [[ -z "$STAGED_FILES" ]]; then
49+
print_status "No staged files to check."
50+
exit 0
51+
fi
52+
53+
# =============================================================================
54+
# CHECK 1: SECRET SCANNING
55+
# =============================================================================
56+
57+
print_status "Running secret scan on staged changes..."
58+
59+
if command -v gitleaks > /dev/null 2>&1; then
60+
# gitleaks v8+ — scan only staged diff
61+
if ! gitleaks protect --staged --no-banner 2>&1; then
62+
print_error "gitleaks detected secrets in staged changes. Commit blocked."
63+
print_error " Fix the issues above, then stage your changes again."
64+
print_error " To see the full report: gitleaks protect --staged --verbose"
65+
HOOK_FAILED=1
66+
else
67+
print_success "gitleaks: no secrets detected."
68+
fi
69+
else
70+
print_warning "gitleaks not found — running built-in secret pattern check."
71+
print_warning " Install for more thorough detection: brew install gitleaks"
72+
73+
# Built-in patterns applied to staged content only (git show :path)
74+
# Each pattern: DESCRIPTION|REGEX
75+
declare -a SECRET_PATTERNS=(
76+
"GitHub classic PAT|ghp_[A-Za-z0-9]{36,}"
77+
"GitHub fine-grained PAT|github_pat_[A-Za-z0-9_]{80,}"
78+
"GitHub OAuth token|gho_[A-Za-z0-9]{36,}"
79+
"GitHub user-to-server token|ghu_[A-Za-z0-9]{36,}"
80+
"GitHub server-to-server token|ghs_[A-Za-z0-9]{36,}"
81+
"GitHub refresh token|ghr_[A-Za-z0-9]{36,}"
82+
"AWS access key ID|AKIA[0-9A-Z]{16}"
83+
"AWS secret access key|(?i)(aws_secret|aws_key|aws_access_key_id)\s*[=:]\s*['\"]?[A-Za-z0-9/+]{40}"
84+
"Private key header|-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY"
85+
"Generic secret assignment|(?i)(password|passwd|secret|api_key|apikey|auth_token|access_token)\s*=\s*['\"][^'\"]{8,}['\"]"
86+
)
87+
88+
SECRETS_FOUND=0
89+
90+
while IFS= read -r file; do
91+
# Skip binary files
92+
if git diff --cached --name-only --diff-filter=ACM -- "$file" | grep -q . && \
93+
git show ":${file}" 2>/dev/null | file - | grep -iq "binary"; then
94+
continue
95+
fi
96+
97+
# Get staged content for this file
98+
STAGED_CONTENT=$(git show ":${file}" 2>/dev/null || true)
99+
[[ -z "$STAGED_CONTENT" ]] && continue
100+
101+
for pattern_entry in "${SECRET_PATTERNS[@]}"; do
102+
description="${pattern_entry%%|*}"
103+
regex="${pattern_entry##*|}"
104+
105+
if echo "$STAGED_CONTENT" | grep -qP "$regex" 2>/dev/null; then
106+
# Report line numbers for the match
107+
matches=$(echo "$STAGED_CONTENT" | grep -nP "$regex" 2>/dev/null | head -5)
108+
print_error "Potential secret detected in ${file}:"
109+
print_error " Pattern : ${description}"
110+
while IFS= read -r match_line; do
111+
print_error " Line : ${match_line}"
112+
done <<< "$matches"
113+
SECRETS_FOUND=1
114+
fi
115+
done
116+
done <<< "$STAGED_FILES"
117+
118+
if [[ "$SECRETS_FOUND" -eq 1 ]]; then
119+
print_error ""
120+
print_error "Secret scan failed. Remove secrets before committing."
121+
print_error "If this is a false positive, install gitleaks and use .gitleaks.toml to allowlist the pattern."
122+
HOOK_FAILED=1
123+
else
124+
print_success "Built-in secret scan: no secrets detected."
125+
fi
126+
fi
127+
128+
# =============================================================================
129+
# CHECK 2: SHELLCHECK — shell script security and correctness
130+
# =============================================================================
131+
132+
# Collect staged .sh files
133+
STAGED_SH_FILES=$(echo "$STAGED_FILES" | grep '\.sh$' || true)
134+
135+
if [[ -n "$STAGED_SH_FILES" ]]; then
136+
print_status "Running shellcheck on staged shell scripts..."
137+
138+
if ! command -v shellcheck > /dev/null 2>&1; then
139+
print_warning "shellcheck not found — skipping shell script linting."
140+
print_warning " Install for shell security checks: brew install shellcheck"
141+
else
142+
SC_FAILED=0
143+
while IFS= read -r sh_file; do
144+
[[ -z "$sh_file" ]] && continue
145+
146+
# Write staged content to a temp file so shellcheck can read it
147+
TEMP_FILE=$(mktemp "/tmp/pre-commit-sc-XXXXXX.sh")
148+
trap 'rm -f "${TEMP_FILE}"' EXIT
149+
150+
git show ":${sh_file}" > "$TEMP_FILE" 2>/dev/null || true
151+
152+
# SC2034 (unused vars) and SC1091 (can't follow source) are noisy
153+
# for this project's pattern of sourcing github-common.sh.
154+
# Severity: error and warning (skip info/style for speed).
155+
if ! shellcheck \
156+
--severity=warning \
157+
--exclude=SC2034,SC1091 \
158+
--shell=bash \
159+
"$TEMP_FILE" 2>&1 | sed "s|${TEMP_FILE}|${sh_file}|g"; then
160+
SC_FAILED=1
161+
fi
162+
163+
rm -f "$TEMP_FILE"
164+
trap - EXIT
165+
done <<< "$STAGED_SH_FILES"
166+
167+
if [[ "$SC_FAILED" -eq 1 ]]; then
168+
print_error "shellcheck found issues in staged scripts. Fix them before committing."
169+
print_error " To disable a specific check inline: # shellcheck disable=SC####"
170+
HOOK_FAILED=1
171+
else
172+
print_success "shellcheck: all staged shell scripts passed."
173+
fi
174+
fi
175+
else
176+
print_status "No staged .sh files — skipping shellcheck."
177+
fi
178+
179+
# =============================================================================
180+
# RESULT
181+
# =============================================================================
182+
183+
if [[ "$HOOK_FAILED" -eq 1 ]]; then
184+
echo ""
185+
print_error "Pre-commit checks failed. Commit blocked."
186+
print_error "Fix the issues above, or bypass with: git commit --no-verify"
187+
exit 1
188+
fi
189+
190+
print_success "All pre-commit checks passed."
191+
exit 0

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,32 @@ Scripts use environment variables for configuration. Common variables include:
8585
> [!NOTE]
8686
> The `*_PREFIX` variables support GitHub Enterprise Server. Set them to your enterprise domain to use these scripts with GHES.
8787
88+
### Pre-commit Hooks
89+
90+
The repository ships versioned pre-commit hooks in `.githooks/`. Run the installer once after cloning:
91+
92+
```bash
93+
./install-hooks.sh
94+
```
95+
96+
This sets `core.hooksPath` to `.githooks/` so Git picks up the hooks automatically on every commit.
97+
98+
**What the hook checks:**
99+
100+
| Check | Tool | Fallback |
101+
|-------|------|----------|
102+
| Secret scanning | [`gitleaks`](https://github.com/gitleaks/gitleaks) | Built-in regex patterns for GitHub tokens, AWS keys, private keys, and generic secrets |
103+
| Shell script security | [`shellcheck`](https://www.shellcheck.net) | Skipped with a warning if not installed |
104+
105+
Install the recommended tools for full coverage:
106+
107+
```bash
108+
brew install gitleaks shellcheck
109+
```
110+
111+
> [!TIP]
112+
> To bypass the hooks in an emergency: `git commit --no-verify`. Use sparingly — the hooks exist to prevent secrets from reaching the remote.
113+
88114
## Scripts
89115

90116
Each script is a self-contained utility designed for a specific task. Navigate to the script's directory, set the required environment variables, and execute.

‎install-hooks.sh‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/bin/bash
2+
# =============================================================================
3+
# install-hooks.sh
4+
#
5+
# Configures Git to use the versioned hooks in .githooks/.
6+
# Run once after cloning the repository.
7+
#
8+
# Usage:
9+
# ./install-hooks.sh
10+
#
11+
# What it does:
12+
# - Sets core.hooksPath to .githooks so Git picks up the pre-commit hook
13+
# - Makes all scripts in .githooks/ executable
14+
#
15+
# Requirements:
16+
# - git
17+
# - gitleaks (recommended — brew install gitleaks)
18+
# - shellcheck (recommended — brew install shellcheck)
19+
# =============================================================================
20+
21+
set -euo pipefail
22+
23+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24+
25+
RED='\033[0;31m'
26+
GREEN='\033[0;32m'
27+
YELLOW='\033[1;33m'
28+
NC='\033[0m'
29+
30+
print_success() { echo -e "${GREEN}[HOOKS]${NC} $1"; }
31+
print_warning() { echo -e "${YELLOW}[HOOKS]${NC} $1"; }
32+
print_error() { echo -e "${RED}[HOOKS]${NC} $1" >&2; }
33+
34+
# Verify we're inside a git repository
35+
if ! git -C "$SCRIPT_DIR" rev-parse --git-dir > /dev/null 2>&1; then
36+
print_error "Not a git repository: ${SCRIPT_DIR}"
37+
exit 1
38+
fi
39+
40+
# Point Git at the versioned hooks directory
41+
git -C "$SCRIPT_DIR" config core.hooksPath .githooks
42+
print_success "core.hooksPath set to .githooks"
43+
44+
# Ensure all hooks are executable
45+
chmod +x "${SCRIPT_DIR}/.githooks/"*
46+
print_success "Hook permissions set."
47+
48+
# Advisory: check for recommended tools
49+
MISSING_TOOLS=()
50+
for tool in gitleaks shellcheck; do
51+
if ! command -v "$tool" > /dev/null 2>&1; then
52+
MISSING_TOOLS+=("$tool")
53+
fi
54+
done
55+
56+
if [[ ${#MISSING_TOOLS[@]} -gt 0 ]]; then
57+
print_warning "Recommended tools not found: ${MISSING_TOOLS[*]}"
58+
print_warning " Install with: brew install ${MISSING_TOOLS[*]}"
59+
print_warning " The pre-commit hook will still run a built-in fallback for secret scanning,"
60+
print_warning " but gitleaks and shellcheck provide significantly better coverage."
61+
fi
62+
63+
print_success "Git hooks installed. They will run automatically on each commit."

0 commit comments

Comments
 (0)