File size: 9,269 Bytes
377417d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | #!/usr/bin/env bash
# Two jobs, in this order (the genomics-agent pattern):
# 1. ADR-0014 security gate β runs on EVERY push. Blocks on findings.
# 2. GitHub mirror sync β only on a PROD deploy (`main` to `origin`). Warns, never blocks.
# The gate runs first, so a blocked deploy can never have already updated the mirror.
#
# --- 1. ADR-0014 pre-push security gate ----------------------------------------------------
#
# `origin` and `hf-dev` ARE HuggingFace Spaces β a push builds a live Space β so there is
# no CI merge gate in front of them. This hook is the enforced check: it runs the shared
# scan (scripts/security_scan.sh) before anything leaves the machine.
#
# It scans the commit being PUSHED, not the working tree it happens to run from. The
# sibling hooks scan the working tree, and on 2026-09-21 that went wrong in the
# orchestrator: pushed from a checkout parked on another lane's branch, the scan audited
# THAT branch's requirements.txt instead of the commit being shipped. Here each pushed sha
# is checked out into a throwaway worktree and scanned there; reports (and the cached
# audit venv) are written to this checkout's security/ so they outlive it.
#
# Install with: make install-hooks
# Bypass (e.g. a docs-only push, knowingly): git push --no-verify
# Set SECURITY_SCAN_STRICT=1 to fail on skipped stages.
set -euo pipefail
# The mirror push below sets this, so that nested push is neither rescanned nor re-mirrored.
[[ -n "${SECURITY_SCAN_DONE:-}" ]] && exit 0
remote_name="${1:-}"
remote_url="${2:-}"
mirror_remote="${MIRROR_REMOTE:-github}"
dev_remote="${DEV_REMOTE:-hf-dev}"
repo_root="$(git rev-parse --show-toplevel)"
# stdin: "<local ref> <local sha> <remote ref> <remote sha>" per ref being pushed. Git sends it
# once and both jobs need it, so it is read here and replayed from the variable.
push_lines="$(cat)"
shas=()
while read -r _lref lsha _rref _rsha; do
[[ -n "${lsha:-}" ]] || continue
case "$lsha" in *[!0]*) ;; *) continue ;; esac # all-zero = a delete; nothing to scan
[[ " ${shas[*]-} " == *" $lsha "* ]] || shas+=("$lsha")
done <<< "$push_lines"
[[ ${#shas[@]} -gt 0 ]] || exit 0
# Sweep leaks from earlier interrupted pushes. Named (not `mktemp -d`) so a leak is
# identifiable, and keyed on LIVENESS rather than age: each scan dir records its hook's PID
# and its scan child's, so a dir whose hook is gone is swept at once while a CONCURRENT
# push's dir is never touched. A dir with no .hookpid predates this and is by definition a
# leak. (2026-09-23: a leaked worktree was found with `git worktree add`'s inner
# `git reset --hard` still sleeping after 2 days 7 hours, in a directory macOS had reaped.)
for stale in "${TMPDIR:-/tmp}"/pre-push-scan.*; do
[[ -d "$stale" ]] || continue
stale_pid="$(cat "$stale/.hookpid" 2>/dev/null || true)"
if [[ -z "$stale_pid" ]] || ! kill -0 "$stale_pid" 2>/dev/null; then
stale_scan="$(cat "$stale/.scanpid" 2>/dev/null || true)"
if [[ -n "$stale_scan" ]] && kill -0 "$stale_scan" 2>/dev/null; then
kill -TERM "-$stale_scan" 2>/dev/null || kill -TERM "$stale_scan" 2>/dev/null || true
sleep 1
if kill -0 "$stale_scan" 2>/dev/null; then
kill -KILL "-$stale_scan" 2>/dev/null || kill -KILL "$stale_scan" 2>/dev/null || true
fi
fi
for wt in "$stale"/wt-*; do
[[ -d "$wt" ]] && git worktree remove --force "$wt" >/dev/null 2>&1
done
rm -rf "$stale"
echo "[pre-push] swept a leaked scan directory from an interrupted push: $(basename "$stale")"
fi
done
git worktree prune >/dev/null 2>&1 || true
tmp="$(mktemp -d "${TMPDIR:-/tmp}/pre-push-scan.XXXXXX")"
echo "$$" > "$tmp/.hookpid"
scan_pid=""
cleanup() {
# Kill the scan BEFORE its directory disappears: deleting the tree under a live child is
# how a gitleaks or `git reset --hard` ends up running in a path that no longer exists.
if [[ -n "$scan_pid" ]] && kill -0 "$scan_pid" 2>/dev/null; then
# Negative pid = the whole process group. run_scan starts the job with `set -m` so it
# gets its own group; signalling the group takes gitleaks / pip-audit / bandit down with
# the shell that launched them, instead of reparenting them to init.
kill -TERM "-$scan_pid" 2>/dev/null || kill -TERM "$scan_pid" 2>/dev/null || true
wait "$scan_pid" 2>/dev/null || true
fi
for wt in "$tmp"/wt-*; do [[ -d "$wt" ]] && git worktree remove --force "$wt" >/dev/null 2>&1; done
git worktree prune >/dev/null 2>&1 || true
rm -rf "$tmp"
}
# EXIT alone is not enough: a non-interactive bash killed by an UNTRAPPED signal dies
# without running its EXIT trap, so Ctrl-C, a closed terminal or a terminated agent session
# left the worktree registered and its directory behind. `trap - EXIT` stops a double run.
# Nothing in-process survives SIGKILL β that case is what the sweep above is for.
on_signal() {
cleanup
trap - EXIT
echo "[pre-push] interrupted β scan worktree cleaned up; push aborted." >&2
exit 130
}
trap cleanup EXIT
trap on_signal INT TERM HUP
# Run the scan as a background job and `wait`: bash defers a trap handler until the running
# FOREGROUND command returns, so a Ctrl-C during a multi-minute scan would not be acted on
# until the scan finished. `wait` IS interruptible, so cleanup runs at once.
run_scan() {
set -m # give the job its own process group, so cleanup can signal the tree
"$@" &
scan_pid=$!
set +m
echo "$scan_pid" > "$tmp/.scanpid"
local rc=0
wait "$scan_pid" || rc=$?
scan_pid=""
rm -f "$tmp/.scanpid"
return "$rc"
}
for sha in "${shas[@]}"; do
short="${sha:0:7}"
# Branches that predate the scan have no script β skip them cleanly rather than block.
if ! git cat-file -e "$sha:scripts/security_scan.sh" 2>/dev/null; then
echo "[pre-push] $short has no scripts/security_scan.sh β skipping scan for it."
continue
fi
echo "[pre-push] running ADR-0014 security scan on the pushed commit $short (git push --no-verify to skip)β¦"
wt="$tmp/wt-$short"
git worktree add --detach --quiet "$wt" "$sha"
# Git exports GIT_DIR & co. into hooks; left set, the scan's own git calls (bandit's
# target discovery, gitleaks) would read the wrong checkout.
scan_job() {
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX \
SECURITY_SCAN_OUT="$repo_root/security" bash "$wt/scripts/security_scan.sh"
}
if run_scan scan_job; then
echo "[pre-push] $short: security scan clean."
else
echo "[pre-push] $short: security scan reported findings β push blocked." >&2
echo "[pre-push] triage the reports in security/, or 'git push --no-verify' to override." >&2
exit 1
fi
done
echo "[pre-push] security scan clean β proceeding."
# --- 2. GitHub mirror sync -----------------------------------------------------------------
# `origin` IS the prod Space and `github` is a source mirror that only runs CI; nothing links
# them, so the mirror fell behind prod by hand-sync on 2026-10-01 more than once. A stale mirror
# means CI runs a SUBSET of production, which reads as a pass. This syncs the mirror's `main`
# at the only moment git offers: when a prod deploy is about to go out.
#
# Two honest limitations (as in pdac-genomics-agent):
# 1. The mirror is pushed FIRST: git has no post-push hook. If the `origin` push then fails,
# `github` is briefly AHEAD of prod. That self-corrects on the next deploy, and CI running a
# superset of production is the milder error.
# 2. A mirror failure WARNS; it does not block. The mirror is CI; `origin` is production. It is
# pushed without force, so a mirror that has moved on (another lane synced it) is left alone.
#
# Only `main` on `origin` is mirrored. `git push hf-dev dev:main` also targets refs/heads/main on
# a huggingface.co URL, so the dev Space is excluded by name AND by URL first β mirroring it
# would put dev code on the mirror's `main`, where CI reads it as production.
case "$remote_name$remote_url" in
"$dev_remote"*|*-dev|*-dev.git|*-dev/) exit 0 ;;
esac
case "$remote_name$remote_url" in
origin*|*huggingface.co/spaces/anne-voigt/pdac-knowledge-agent) ;;
*) exit 0 ;;
esac
main_sha=""
while read -r _lref lsha rref _rsha; do
[[ "${rref:-}" == "refs/heads/main" ]] || continue
case "${lsha:-}" in *[!0]*) main_sha="$lsha" ;; esac # all-zero = a DELETE; never mirror it
done <<< "$push_lines"
[[ -n "$main_sha" ]] || exit 0 # nothing headed for main: not a deploy
if ! git remote get-url "$mirror_remote" >/dev/null 2>&1; then
echo "[pre-push] no '$mirror_remote' remote β the GitHub mirror will fall behind this deploy." >&2
echo "[pre-push] git remote add $mirror_remote https://github.com/Anne-Voigt/pdac-knowledge-agent.git" >&2
exit 0
fi
echo "[pre-push] mirroring ${main_sha:0:7} to '$mirror_remote' main so the mirror matches prodβ¦"
if SECURITY_SCAN_DONE=1 git push --quiet "$mirror_remote" "$main_sha:refs/heads/main"; then
echo "[pre-push] mirror in sync β proceeding with the deploy."
else
echo "[pre-push] WARNING: mirror push failed (often: it already moved on). The deploy proceeds." >&2
echo "[pre-push] Check it after the deploy: git fetch $mirror_remote && git log --oneline -1 $mirror_remote/main" >&2
fi
exit 0
|