Layer secret leak guardrails
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled

This commit is contained in:
Hermes Agent
2026-05-22 21:23:15 +00:00
parent 0b528cbb5f
commit d62a391cbf
5 changed files with 195 additions and 47 deletions

18
.githooks/pre-push Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
scanner="$repo_root/scripts/scan-secret-bearing-artifacts.sh"
while read -r local_ref local_sha remote_ref remote_sha; do
[[ -n "$local_sha" ]] || continue
if [[ "$local_sha" =~ ^0+$ ]]; then
continue
fi
if [[ "$remote_sha" =~ ^0+$ ]]; then
"$scanner" --unpublished "$local_sha"
else
"$scanner" --git-range "$remote_sha..$local_sha"
fi
done

33
.github/workflows/secret-guardrails.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: secret-guardrails
on:
push:
pull_request:
jobs:
artifact-secret-scan:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Make scanner executable
run: chmod +x scripts/scan-secret-bearing-artifacts.sh
- name: Scan all tracked infra artifacts for embedded secrets
run: scripts/scan-secret-bearing-artifacts.sh --tracked
gitleaks:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -239,27 +239,36 @@ See:
- `docs/operations/INCIDENT_2026-05-22_UNIFI_BASELINE_SECRET_LEAK.md` - `docs/operations/INCIDENT_2026-05-22_UNIFI_BASELINE_SECRET_LEAK.md`
- `home/doris-dashboard/docs/baselines/README.md` - `home/doris-dashboard/docs/baselines/README.md`
### Pre-commit guardrail for infra artifacts ### Git hook + CI guardrails for infra artifacts
The repo now includes a narrow pre-commit scanner aimed specifically at catching secret-bearing controller/export artifacts before push. The repo now includes layered guardrails aimed specifically at catching secret-bearing controller/export artifacts before they leave a workstation.
Files: Files:
- `scripts/scan-secret-bearing-artifacts.sh` - `scripts/scan-secret-bearing-artifacts.sh`
- `.githooks/pre-commit` - `.githooks/pre-commit`
- `.githooks/pre-push`
- `.github/workflows/secret-guardrails.yml`
Enable it once per clone: Enable the hooks once per clone:
```bash ```bash
git config core.hooksPath .githooks git config core.hooksPath .githooks
chmod +x .githooks/pre-commit scripts/scan-secret-bearing-artifacts.sh chmod +x .githooks/pre-commit .githooks/pre-push scripts/scan-secret-bearing-artifacts.sh
``` ```
Run it manually on staged changes: Manual runs:
```bash ```bash
scripts/scan-secret-bearing-artifacts.sh --staged scripts/scan-secret-bearing-artifacts.sh --staged
scripts/scan-secret-bearing-artifacts.sh --tracked
scripts/scan-secret-bearing-artifacts.sh --git-range origin/main..HEAD
``` ```
What each layer does:
- `pre-commit`: scans staged files before a local commit is created
- `pre-push`: scans the commits about to be pushed, including unpublished branch history
- GitHub Actions: runs the narrow artifact scan across tracked files and also runs Gitleaks on pushes and pull requests
What it is for: What it is for:
- raw baselines - raw baselines
- exports - exports
@@ -268,7 +277,9 @@ What it is for:
- similar machine-generated infra artifacts - similar machine-generated infra artifacts
What it is not: What it is not:
- a full replacement for GitHub/GitGuardian or a generic secret scanner for every file type - a full replacement for operator judgment about what belongs in the repo
- proof that a raw export is safe just because the scanner stayed quiet
- a reason to skip rotating live secrets if one ever does leak
## Security Reminders ## Security Reminders

View File

@@ -29,8 +29,13 @@ See:
2. Inspect them for secret-bearing fields. 2. Inspect them for secret-bearing fields.
3. Commit only a markdown summary or an explicitly redacted artifact. 3. Commit only a markdown summary or an explicitly redacted artifact.
4. Prefer human-readable summaries over raw appliance dumps. 4. Prefer human-readable summaries over raw appliance dumps.
5. Enable the repo guardrail in each clone: 5. Enable the repo guardrails in each clone:
```bash ```bash
git config core.hooksPath .githooks git config core.hooksPath .githooks
chmod +x .githooks/pre-commit scripts/scan-secret-bearing-artifacts.sh chmod +x .githooks/pre-commit .githooks/pre-push scripts/scan-secret-bearing-artifacts.sh
```
6. Before a risky push or cleanup branch, run the manual checks too:
```bash
scripts/scan-secret-bearing-artifacts.sh --tracked
scripts/scan-secret-bearing-artifacts.sh --git-range origin/main..HEAD
``` ```

View File

@@ -1,78 +1,159 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
mode="${1:---staged}"
repo_root="$(git rev-parse --show-toplevel)" repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root" cd "$repo_root"
if [[ "$mode" != "--staged" && "$mode" != "--files" ]]; then
echo "usage: $0 [--staged|--files <path> ...]" >&2
exit 2
fi
artifact_path_regex='(^|/)(docs/baselines/|baselines/|snapshots?/|exports?/|dumps?/|backups?/|diagnostics?/|artifacts?/|tmp/|temp/)|(^|/).*(baseline|snapshot|export|dump|diagnostic).*(\.json|\.ya?ml|\.txt|\.conf)?$' artifact_path_regex='(^|/)(docs/baselines/|baselines/|snapshots?/|exports?/|dumps?/|backups?/|diagnostics?/|artifacts?/|tmp/|temp/)|(^|/).*(baseline|snapshot|export|dump|diagnostic).*(\.json|\.ya?ml|\.txt|\.conf)?$'
placeholder_regex='(REDACTED|CHANGE_ME|<redacted>|example|placeholder|your[_ -]?token|your[_ -]?key)' placeholder_regex='(REDACTED|CHANGE_ME|<redacted>|example|placeholder|your[_ -]?token|your[_ -]?key)'
empty_tree='4b825dc642cb6eb9a060e54bf8d69288fbee4904'
usage() {
cat >&2 <<'EOF'
usage:
scripts/scan-secret-bearing-artifacts.sh --staged
scripts/scan-secret-bearing-artifacts.sh --tracked
scripts/scan-secret-bearing-artifacts.sh --files <path> ...
scripts/scan-secret-bearing-artifacts.sh --git-range <revset>
scripts/scan-secret-bearing-artifacts.sh --unpublished <tip-commit>
EOF
exit 2
}
mode="${1:---staged}"
shift || true
declare -a files=() declare -a files=()
if [[ "$mode" == "--staged" ]]; then declare -a commit_specs=()
while IFS= read -r f; do
[[ -n "$f" ]] && files+=("$f")
done < <(git diff --cached --name-only --diff-filter=ACMR)
else
shift
for f in "$@"; do
files+=("$f")
done
fi
if [[ ${#files[@]} -eq 0 ]]; then case "$mode" in
--staged)
while IFS= read -r -d '' f; do
files+=("$f")
done < <(git diff --cached --name-only --diff-filter=ACMR -z)
;;
--tracked)
while IFS= read -r -d '' f; do
files+=("$f")
done < <(git ls-files -z)
;;
--files)
[[ $# -ge 1 ]] || usage
files=("$@")
;;
--git-range)
[[ $# -eq 1 ]] || usage
while IFS= read -r commit; do
[[ -n "$commit" ]] && commit_specs+=("$commit")
done < <(git rev-list --reverse "$1")
;;
--unpublished)
[[ $# -eq 1 ]] || usage
while IFS= read -r commit; do
[[ -n "$commit" ]] && commit_specs+=("$commit")
done < <(git rev-list --reverse "$1" --not --remotes)
;;
*)
usage
;;
esac
if [[ ${#files[@]} -eq 0 && ${#commit_specs[@]} -eq 0 ]]; then
exit 0 exit 0
fi fi
failures=0 python_scan() {
local display_name="$1"
local source_mode="$2"
local source_value="$3"
scan_file() { python3 - "$display_name" "$placeholder_regex" "$source_mode" "$source_value" <<'PY'
local file="$1"
[[ -f "$file" ]] || return 0
[[ "$file" =~ $artifact_path_regex ]] || return 0
python3 - "$file" "$placeholder_regex" <<'PY'
from pathlib import Path from pathlib import Path
import re, sys import re
import subprocess
import sys
path = Path(sys.argv[1]) label = sys.argv[1]
placeholder = re.compile(sys.argv[2], re.I) placeholder = re.compile(sys.argv[2], re.I)
text = path.read_text(errors='ignore') source_mode = sys.argv[3]
lines = text.splitlines() source_value = sys.argv[4]
if source_mode == 'file':
text = Path(source_value).read_text(errors='ignore')
else:
text = subprocess.check_output(
['git', 'show', source_value],
text=True,
errors='ignore',
)
lines = text.splitlines()
patterns = [ patterns = [
(re.compile(r'"x_wireguard_private_key"\s*:\s*"([^"]+)"', re.I), 'embedded WireGuard private key'), (re.compile(r'"x_wireguard_private_key"\s*:\s*"([^"]+)"', re.I), 'embedded WireGuard private key'),
(re.compile(r'"wireguard_client_configuration_file"\s*:\s*"([^"\\]|\\.)*PrivateKey = [A-Za-z0-9+/=]{20,}', re.I), 'embedded WireGuard client config with private key'), (re.compile(r'"wireguard_client_configuration_file"\s*:\s*"([^"\\]|\\.)*PrivateKey = [A-Za-z0-9+/=]{20,}', re.I), 'embedded WireGuard client config with private key'),
(re.compile(r'PrivateKey\s*=\s*([A-Za-z0-9+/=]{20,})'), 'WireGuard private key line'), (re.compile(r'PrivateKey\s*=\s*([A-Za-z0-9+/=]{20,})'), 'WireGuard private key line'),
(re.compile(r'BEGIN [A-Z0-9 ]*PRIVATE KEY', re.I), 'private key block header'),
(re.compile(r'Authorization\s*[:=]\s*(Bearer\s+)?[A-Za-z0-9._~+/=-]{16,}', re.I), 'authorization header/token material'), (re.compile(r'Authorization\s*[:=]\s*(Bearer\s+)?[A-Za-z0-9._~+/=-]{16,}', re.I), 'authorization header/token material'),
(re.compile(r'"(token|api[_-]?key|client[_-]?secret|password|secret|cookie)"\s*:\s*"([^"]{12,})"', re.I), 'suspicious secret-like JSON field'), (re.compile(r'"(token|api[_-]?key|client[_-]?secret|password|secret|cookie)"\s*:\s*"([^"]{12,})"', re.I), 'suspicious secret-like JSON field'),
] ]
for idx, line in enumerate(lines, start=1): for idx, line in enumerate(lines, start=1):
for regex, label in patterns: for regex, finding in patterns:
m = regex.search(line) match = regex.search(line)
if not m: if not match:
continue continue
if placeholder.search(line): if placeholder.search(line):
continue continue
print(f'{path}:{idx}: {label}') print(f'{label}:{idx}: {finding}')
print(f' {line[:220]}') print(f' {line[:220]}')
sys.exit(1) sys.exit(1)
sys.exit(0) sys.exit(0)
PY PY
} }
for file in "${files[@]}"; do scan_worktree_file() {
if ! scan_file "$file"; then local file="$1"
failures=1 [[ -f "$file" ]] || return 0
fi [[ "$file" =~ $artifact_path_regex ]] || return 0
done python_scan "$file" file "$file"
}
scan_commit_file() {
local commit="$1"
local file="$2"
git cat-file -e "$commit:$file" 2>/dev/null || return 0
[[ "$file" =~ $artifact_path_regex ]] || return 0
python_scan "$commit:$file" git_object "$commit:$file"
}
failures=0
if [[ ${#files[@]} -gt 0 ]]; then
for file in "${files[@]}"; do
if ! scan_worktree_file "$file"; then
failures=1
fi
done
fi
if [[ ${#commit_specs[@]} -gt 0 ]]; then
declare -A seen=()
for commit in "${commit_specs[@]}"; do
parent="$empty_tree"
if git rev-parse --verify "$commit^" >/dev/null 2>&1; then
parent="$commit^"
fi
while IFS= read -r -d '' file; do
key="$commit:$file"
[[ -n "$file" ]] || continue
[[ -n "${seen[$key]:-}" ]] && continue
seen[$key]=1
if ! scan_commit_file "$commit" "$file"; then
failures=1
fi
done < <(git diff --name-only --diff-filter=ACMR -z "$parent" "$commit")
done
fi
if [[ "$failures" -ne 0 ]]; then if [[ "$failures" -ne 0 ]]; then
cat >&2 <<'EOF' cat >&2 <<'EOF'
@@ -80,7 +161,7 @@ if [[ "$failures" -ne 0 ]]; then
Secret-bearing artifact scan failed. Secret-bearing artifact scan failed.
This guardrail is intentionally narrow: it is meant to stop raw controller/export/baseline artifacts This guardrail is intentionally narrow: it is meant to stop raw controller/export/baseline artifacts
from being committed with embedded keys or tokens. from being committed or pushed with embedded keys or tokens.
Fix options: Fix options:
- remove the raw artifact from the commit - remove the raw artifact from the commit