mirror of
https://github.com/Sea-Haven-Industries/security-review.git
synced 2026-10-06 13:32:16 +00:00
fix(hooks): fail closed when pre-push scanner missing
This commit is contained in:
parent
51f7905cc7
commit
02e20ec724
4 changed files with 126 additions and 20 deletions
|
|
@ -42,6 +42,10 @@ The global mode sets `git config --global core.hooksPath ~/.config/git/hooks`. S
|
||||||
its own local `core.hooksPath` overrides the global hook — install per-repo there. See memory
|
its own local `core.hooksPath` overrides the global hook — install per-repo there. See memory
|
||||||
`reference_global_security_review_hook`.
|
`reference_global_security_review_hook`.
|
||||||
|
|
||||||
|
If `review.sh` is missing or not executable at the configured path, the pre-push hook **fails closed**
|
||||||
|
(nonzero exit) instead of skipping the gate. Repair by setting `SH_REVIEW_SH` to the scanner path, or
|
||||||
|
reinstall with `./install-hooks.sh --global`.
|
||||||
|
|
||||||
- `--global` also creates the machine-level suppressions dir
|
- `--global` also creates the machine-level suppressions dir
|
||||||
(`${SH_SECURITY_SUPPRESSIONS_DIR:-~/.config/sea-haven/security-review}`): the per-repo file
|
(`${SH_SECURITY_SUPPRESSIONS_DIR:-~/.config/sea-haven/security-review}`): the per-repo file
|
||||||
`<dir>/<repo-basename>/suppressions.json` is preferred by the hooks over repo-local
|
`<dir>/<repo-basename>/suppressions.json` is preferred by the hooks over repo-local
|
||||||
|
|
|
||||||
|
|
@ -8,26 +8,28 @@ set -uo pipefail
|
||||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
|
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
|
||||||
[ -f "$REPO_ROOT/.security-review-skip" ] && exit 0
|
[ -f "$REPO_ROOT/.security-review-skip" ] && exit 0
|
||||||
REVIEW_SH="${SH_REVIEW_SH:-$HOME/Documents/repositories/seahaven/security-review/review.sh}"
|
REVIEW_SH="${SH_REVIEW_SH:-$HOME/Documents/repositories/seahaven/security-review/review.sh}"
|
||||||
if [ -f "$REVIEW_SH" ]; then
|
if [ ! -x "$REVIEW_SH" ]; then
|
||||||
SUP=()
|
echo "security-review: review.sh is missing or not executable at $REVIEW_SH" >&2
|
||||||
# Suppressions: prefer a MACHINE-LEVEL file kept out of repo history
|
echo "Repair: set SH_REVIEW_SH to the scanner path, or reinstall with:" >&2
|
||||||
# (<dir>/<repo-basename>/suppressions.json), else fall back to a repo-local
|
echo " ${HOME}/Documents/repositories/seahaven/security-review/install-hooks.sh --global" >&2
|
||||||
# .security-review/suppressions.json. Keyed by repo basename — adequate for the
|
exit 1
|
||||||
# current single-namespace layout under ~/Documents/repositories.
|
fi
|
||||||
MACHINE_SUP="${SH_SECURITY_SUPPRESSIONS_DIR:-$HOME/.config/sea-haven/security-review}/$(basename "$REPO_ROOT")/suppressions.json"
|
SUP=()
|
||||||
if [ -f "$MACHINE_SUP" ]; then
|
# Suppressions: prefer a MACHINE-LEVEL file kept out of repo history
|
||||||
SUP=(--suppressions "$MACHINE_SUP")
|
# (<dir>/<repo-basename>/suppressions.json), else fall back to a repo-local
|
||||||
elif [ -f "$REPO_ROOT/.security-review/suppressions.json" ]; then
|
# .security-review/suppressions.json. Keyed by repo basename — adequate for the
|
||||||
SUP=(--suppressions "$REPO_ROOT/.security-review/suppressions.json")
|
# current single-namespace layout under ~/Documents/repositories.
|
||||||
fi
|
MACHINE_SUP="${SH_SECURITY_SUPPRESSIONS_DIR:-$HOME/.config/sea-haven/security-review}/$(basename "$REPO_ROOT")/suppressions.json"
|
||||||
echo "security-review: scanning $REPO_ROOT (scanners-only) before push..." >&2
|
if [ -f "$MACHINE_SUP" ]; then
|
||||||
# ${SUP[@]+"${SUP[@]}"} = bash-3.2-safe expansion of a possibly-empty array under set -u.
|
SUP=(--suppressions "$MACHINE_SUP")
|
||||||
if ! bash "$REVIEW_SH" --scanners-only ${SUP[@]+"${SUP[@]}"} "$REPO_ROOT"; then
|
elif [ -f "$REPO_ROOT/.security-review/suppressions.json" ]; then
|
||||||
echo "security-review: BLOCKED (confirmed crit/high). Fix it, suppress with justification, or 'git push --no-verify' to override." >&2
|
SUP=(--suppressions "$REPO_ROOT/.security-review/suppressions.json")
|
||||||
exit 1
|
fi
|
||||||
fi
|
echo "security-review: scanning $REPO_ROOT (scanners-only) before push..." >&2
|
||||||
else
|
# ${SUP[@]+"${SUP[@]}"} = bash-3.2-safe expansion of a possibly-empty array under set -u.
|
||||||
echo "security-review: review.sh not found at $REVIEW_SH (set SH_REVIEW_SH) — skipping gate" >&2
|
if ! bash "$REVIEW_SH" --scanners-only ${SUP[@]+"${SUP[@]}"} "$REPO_ROOT"; then
|
||||||
|
echo "security-review: BLOCKED (confirmed crit/high). Fix it, suppress with justification, or 'git push --no-verify' to override." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Don't silently disable a repo-local pre-push hook: chain to it if present.
|
# Don't silently disable a repo-local pre-push hook: chain to it if present.
|
||||||
LOCAL_HOOK="$REPO_ROOT/.git/hooks/pre-push"
|
LOCAL_HOOK="$REPO_ROOT/.git/hooks/pre-push"
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ case "${1:-}" in
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Done. Every repo on this machine is now gated by review.sh --scanners-only before push."
|
echo "Done. Every repo on this machine is now gated by review.sh --scanners-only before push."
|
||||||
|
echo "A missing or non-executable review.sh fails the push closed; repair with this installer or SH_REVIEW_SH."
|
||||||
echo "Caveats: a repo that sets its OWN local core.hooksPath (e.g. husky) overrides this global hook"
|
echo "Caveats: a repo that sets its OWN local core.hooksPath (e.g. husky) overrides this global hook"
|
||||||
echo " — run 'install-hooks.sh <that-repo>' to gate it per-repo. Skip a repo with a"
|
echo " — run 'install-hooks.sh <that-repo>' to gate it per-repo. Skip a repo with a"
|
||||||
echo " .security-review-skip file at its root; bypass once with 'git push --no-verify'."
|
echo " .security-review-skip file at its root; bypass once with 'git push --no-verify'."
|
||||||
|
|
|
||||||
99
tests/test_pre_push_hook.py
Normal file
99
tests/test_pre_push_hook.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Regression tests for the global pre-push security hook fail-closed behavior."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PRE_PUSH = REPO_ROOT / "hooks" / "pre-push"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_git_repo(tmp_path: Path) -> Path:
|
||||||
|
"""Minimal git repo so the hook's git rev-parse succeeds."""
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.email", "test@example.com"],
|
||||||
|
cwd=repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.name", "Test"],
|
||||||
|
cwd=repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
(repo / "README").write_text("x\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "add", "README"], cwd=repo, check=True, capture_output=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "commit", "-m", "init"],
|
||||||
|
cwd=repo,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def _run_hook(
|
||||||
|
repo: Path, review_sh: str | Path, *, env_extra: dict[str, str] | None = None
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["SH_REVIEW_SH"] = str(review_sh)
|
||||||
|
if env_extra:
|
||||||
|
env.update(env_extra)
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", str(PRE_PUSH)],
|
||||||
|
cwd=repo,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_scanner_fails_closed(temp_git_repo: Path, tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "no-such-review.sh"
|
||||||
|
result = _run_hook(temp_git_repo, missing)
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert str(missing) in result.stderr
|
||||||
|
assert "missing or not executable" in result.stderr
|
||||||
|
assert "install-hooks.sh --global" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_executable_scanner_fails_closed(
|
||||||
|
temp_git_repo: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
stub = tmp_path / "review.sh"
|
||||||
|
stub.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
||||||
|
stub.chmod(0o644)
|
||||||
|
result = _run_hook(temp_git_repo, stub)
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert str(stub) in result.stderr
|
||||||
|
assert "missing or not executable" in result.stderr
|
||||||
|
assert "install-hooks.sh --global" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_scanner_success_allows_push(temp_git_repo: Path, tmp_path: Path) -> None:
|
||||||
|
stub = tmp_path / "review.sh"
|
||||||
|
stub.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
||||||
|
stub.chmod(0o755)
|
||||||
|
result = _run_hook(temp_git_repo, stub)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "BLOCKED" not in result.stderr
|
||||||
|
assert "missing or not executable" not in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_scanner_block_blocks_push(temp_git_repo: Path, tmp_path: Path) -> None:
|
||||||
|
stub = tmp_path / "review.sh"
|
||||||
|
stub.write_text("#!/usr/bin/env bash\nexit 1\n", encoding="utf-8")
|
||||||
|
stub.chmod(0o755)
|
||||||
|
result = _run_hook(temp_git_repo, stub)
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "BLOCKED" in result.stderr
|
||||||
Loading…
Add table
Reference in a new issue