PreToolUse hook for the Bash tool. When the user writes a pipeline whose
left side changes state (git push, kubectl apply, curl -X PUT, ...) and
whose exit status is then consumed by && / || / an if-condition, the
hook exits 2 and surfaces an advisory naming the masking command and
suggesting ${PIPESTATUS[0]} or dropping the pipe.
Always exits 0 on parse failure, missing python3, empty input, malformed
JSON, or anything it does not recognise — a hook that breaks Bash is
worse than the bug it prevents.
Real-process tests in hooks/tests/ feed JSON on stdin as a subprocess and
include mutation cases (six targeted matcher breakages) to prove each
assertion can actually fail.
338 lines
11 KiB
Bash
Executable File
338 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Tests for hooks/bash-pipeline-guard.sh
|
|
#
|
|
# Convention: bash, `set -uo pipefail`, one PASS:/FAIL: per assertion,
|
|
# FAIL=1 aggregate, ALL_OK on full pass.
|
|
#
|
|
# Each test invokes the hook as a subprocess with real JSON on stdin — never
|
|
# source the hook, so we test its actual stdin/stdout/stderr contract.
|
|
|
|
set -uo pipefail
|
|
|
|
PASS_COUNT=0
|
|
FAIL_COUNT=0
|
|
FAIL=0
|
|
|
|
HOOK="$(cd "$(dirname "$0")/.." && pwd)/bash-pipeline-guard.sh"
|
|
[ -x "$HOOK" ] || {
|
|
echo "FATAL: hook not found or not executable: $HOOK" >&2
|
|
exit 2
|
|
}
|
|
command -v python3 >/dev/null 2>&1 || {
|
|
echo "FATAL: python3 required to build test JSON" >&2
|
|
exit 2
|
|
}
|
|
|
|
pass() {
|
|
printf 'PASS: %s\n' "$1"
|
|
PASS_COUNT=$((PASS_COUNT + 1))
|
|
}
|
|
fail() {
|
|
printf 'FAIL: %s\n' "$1"
|
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
|
FAIL=1
|
|
}
|
|
|
|
# Build the JSON envelope the hook expects.
|
|
json_for_cmd() {
|
|
python3 -c '
|
|
import json, sys
|
|
print(json.dumps({"tool_input": {"command": sys.argv[1]}}))
|
|
' "$1"
|
|
}
|
|
|
|
# Run the hook with the given stdin payload. Captures stderr to a file (so the
|
|
# test name stays readable on stdout) and stdout to nothing.
|
|
run_hook_with_stdin() {
|
|
local payload="$1"
|
|
local stderr_file
|
|
stderr_file="$(mktemp)"
|
|
printf '%s' "$payload" | "$HOOK" 2>"$stderr_file" >/dev/null
|
|
HOOK_RC=$?
|
|
HOOK_STDERR="$(cat "$stderr_file")"
|
|
rm -f "$stderr_file"
|
|
}
|
|
|
|
# assert_flag <label> <command>
|
|
# Hook must exit 2 and emit a non-empty stderr line.
|
|
assert_flag() {
|
|
local label="$1" cmd="$2"
|
|
run_hook_with_stdin "$(json_for_cmd "$cmd")"
|
|
if [ "$HOOK_RC" -eq 2 ] && [ -n "$HOOK_STDERR" ]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label (expected exit=2 + stderr; got exit=$HOOK_RC stderr='$HOOK_STDERR')"
|
|
fi
|
|
}
|
|
|
|
# assert_pass <label> <command>
|
|
# Hook must exit 0 (no blocking). Stderr allowed to be non-empty — the brief
|
|
# says exit 0 for anything the hook doesn't understand, including cases where
|
|
# it might surface information; but in practice our hook is silent on pass.
|
|
assert_pass() {
|
|
local label="$1" cmd="$2"
|
|
run_hook_with_stdin "$(json_for_cmd "$cmd")"
|
|
if [ "$HOOK_RC" -eq 0 ]; then
|
|
pass "$label"
|
|
else
|
|
fail "$label (expected exit=0; got exit=$HOOK_RC stderr='$HOOK_STDERR')"
|
|
fi
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# SHOULD FLAG — state-changing left side + pipe + && / || / if-test consumer
|
|
# -----------------------------------------------------------------------------
|
|
|
|
assert_flag "git push masked by tail && echo pushed (brief example)" \
|
|
'git push -q origin main 2>&1 | tail -1 && echo "pushed"'
|
|
|
|
assert_flag "git push simple mask" \
|
|
'git push origin main | head && echo done'
|
|
|
|
assert_flag "git commit masked" \
|
|
'git commit -m msg file.txt | head && echo done'
|
|
|
|
assert_flag "git merge masked" \
|
|
'git merge feature | head && echo done'
|
|
|
|
assert_flag "git rebase masked" \
|
|
'git rebase main | head && echo done'
|
|
|
|
assert_flag "kubectl apply masked" \
|
|
'kubectl apply -f foo.yaml | head && echo done'
|
|
|
|
assert_flag "kubectl delete masked" \
|
|
'kubectl delete pod foo | head && echo done'
|
|
|
|
assert_flag "kubectl patch masked" \
|
|
'kubectl patch svc foo -p {} | head && echo done'
|
|
|
|
assert_flag "docker push masked" \
|
|
'docker push registry.example.com/img | head && echo done'
|
|
|
|
assert_flag "helm upgrade masked" \
|
|
'helm upgrade foo bar | head && echo done'
|
|
|
|
assert_flag "helm install masked" \
|
|
'helm install foo bar | head && echo done'
|
|
|
|
assert_flag "curl -X POST masked" \
|
|
'curl -X POST https://api.example.com | jq && echo done'
|
|
|
|
assert_flag "curl -X PUT masked" \
|
|
'curl -X PUT https://api.example.com | jq && echo done'
|
|
|
|
assert_flag "curl -X DELETE masked" \
|
|
'curl -X DELETE https://api.example.com | jq && echo done'
|
|
|
|
assert_flag "curl --data masked" \
|
|
"curl --data 'foo' https://api.example.com | jq && echo done"
|
|
|
|
assert_flag "git push consumed by ||" \
|
|
'git push | head || echo fallback'
|
|
|
|
assert_flag "multi-pipe state-changing left (git push | tee | tail)" \
|
|
'git push | tee /tmp/log | tail && echo done'
|
|
|
|
assert_flag "if-test consumes pipeline status (git push)" \
|
|
'if git push origin main | head; then echo done; fi'
|
|
|
|
assert_flag "if-test consumes pipeline status (kubectl apply)" \
|
|
'if kubectl apply -f foo.yaml | tee /tmp/log; then deploy; fi'
|
|
|
|
assert_flag "state-changing verb with quoted arg" \
|
|
'git push "origin main" | head && echo done'
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# SHOULD NOT FLAG — read-only, handled, or non-consuming contexts
|
|
# -----------------------------------------------------------------------------
|
|
|
|
assert_pass "no pipe, just &&" \
|
|
'git push origin main && echo done'
|
|
|
|
assert_pass "pipe with semicolon (no consumption)" \
|
|
'git push origin main | head -1; echo done'
|
|
|
|
assert_pass "set -o pipefail suppresses flag" \
|
|
'set -o pipefail; git push | head && echo done'
|
|
|
|
assert_pass "\${PIPESTATUS[0]} suppresses flag" \
|
|
'git push | head; echo ${PIPESTATUS[0]} && echo done'
|
|
|
|
assert_pass "git log is read-only" \
|
|
'git log | head && echo done'
|
|
|
|
assert_pass "ls is read-only" \
|
|
'ls | grep foo && echo done'
|
|
|
|
assert_pass "cat is read-only" \
|
|
'cat file | grep foo && echo done'
|
|
|
|
assert_pass "grep is read-only" \
|
|
'grep foo file | head && echo done'
|
|
|
|
assert_pass "curl without write method" \
|
|
'curl https://api.example.com | jq && echo done'
|
|
|
|
assert_pass "git status is read-only" \
|
|
'git status | head && echo done'
|
|
|
|
assert_pass "state verb quoted (echo \"git push\")" \
|
|
'echo "git push" | head && echo done'
|
|
|
|
assert_pass "then keyword is in if-body, not after pipe" \
|
|
'if [ -f foo ]; then git push | head; fi'
|
|
|
|
# Malformed JSON, empty input, command with quotes and newlines
|
|
assert_pass "malformed JSON does not crash" \
|
|
'not json {{{'
|
|
|
|
assert_pass "empty JSON object does not crash" \
|
|
'{}'
|
|
|
|
assert_pass "missing tool_input does not crash" \
|
|
'{"foo":"bar"}'
|
|
|
|
assert_pass "tool_input as non-dict does not crash" \
|
|
'{"tool_input":"oops"}'
|
|
|
|
assert_pass "empty stdin does not crash" \
|
|
''
|
|
|
|
# Command containing quotes and newlines. State verb is inside an echo — must
|
|
# not flag. The newlines are encoded as literal "\n" in JSON; the hook sees a
|
|
# real newline.
|
|
assert_pass "command with embedded newline (state verb quoted)" \
|
|
$'echo "git push" | head && echo done'
|
|
|
|
# A real multi-line state-changing push that the hook should still flag.
|
|
assert_flag "real state-changing push spanning newlines" \
|
|
$'git push \\\n | head && echo done'
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Mutation tests — prove the suite can catch real regressions
|
|
# -----------------------------------------------------------------------------
|
|
#
|
|
# Each mutation rewrites one piece of the matcher in a temp copy of the hook,
|
|
# runs a previously-passing case through it, and expects the hook to either
|
|
# fail to flag (when we broke detection of a flag case) or unexpectedly flag
|
|
# (when we broke detection of a pass case). A mutation that does not change
|
|
# the outcome is a test that cannot fail.
|
|
|
|
echo
|
|
echo "--- Mutation tests ---"
|
|
|
|
# Apply a string substitution to a copy of the hook. Use python so we don't
|
|
# fight BRE/ERE escaping.
|
|
apply_mutation() {
|
|
local hook_src="$1" mutation="$2" out="$3"
|
|
python3 -c '
|
|
import sys
|
|
old, new = sys.argv[1].split("=>", 1)
|
|
with open(sys.argv[2]) as f:
|
|
src = f.read()
|
|
if old not in src:
|
|
sys.stderr.write("mutation pattern not found: " + repr(old) + "\n")
|
|
sys.exit(1)
|
|
with open(sys.argv[3], "w") as f:
|
|
f.write(src.replace(old, new, 1))
|
|
' "$mutation" "$hook_src" "$out"
|
|
}
|
|
|
|
# verify_mutation <name> <mutation> <cmd> <expected>
|
|
# expected = "flag" → the broken hook must no longer flag this should-flag case
|
|
# expected = "no-flag" → the broken hook must no longer crash or block a pass case
|
|
verify_mutation() {
|
|
local name="$1" mutation="$2" cmd="$3" expected="$4"
|
|
local broken
|
|
broken="$(mktemp -t bpgmut.XXXXXX)" || {
|
|
fail "mutation $name: mktemp failed"
|
|
return
|
|
}
|
|
if ! apply_mutation "$HOOK" "$mutation" "$broken"; then
|
|
fail "mutation $name: could not apply"
|
|
rm -f "$broken"
|
|
return
|
|
fi
|
|
chmod +x "$broken"
|
|
local stderr_file rc stderr_content
|
|
stderr_file="$(mktemp)"
|
|
json_for_cmd "$cmd" | "$broken" 2>"$stderr_file" >/dev/null
|
|
rc=$?
|
|
stderr_content="$(cat "$stderr_file")"
|
|
rm -f "$stderr_file" "$broken"
|
|
|
|
case "$expected" in
|
|
flag)
|
|
if [ "$rc" -eq 2 ]; then
|
|
fail "mutation $name: still flags (rc=$rc) — test did NOT go red"
|
|
else
|
|
pass "mutation $name: no longer flags (rc=$rc) — test went red"
|
|
fi
|
|
;;
|
|
no-flag)
|
|
if [ "$rc" -eq 0 ]; then
|
|
fail "mutation $name: pass-case still passes (rc=$rc) — test did NOT go red"
|
|
else
|
|
pass "mutation $name: pass-case now fails (rc=$rc stderr=$stderr_content) — test went red"
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
MUTATION_OK=1
|
|
|
|
# Mutation A: kill the git-push pattern. The flag-case must stop being flagged.
|
|
verify_mutation "git-push pattern" \
|
|
'r"\bgit\s+push\b"=>r"\bgit\s+pull\b"' \
|
|
'git push -q origin main 2>&1 | tail -1 && echo "pushed"' \
|
|
"flag"
|
|
|
|
# Mutation B: never consume \|| when present. The flag-case with && stays flagged,
|
|
# so instead break the && check.
|
|
verify_mutation "and-or consumption check" \
|
|
're.search(r"&&|\|\|", rest)=>re.search(r"XXX", rest)' \
|
|
'git push | head && echo done' \
|
|
"flag"
|
|
|
|
# Mutation C: treat every `|` as a `||` and skip it, so the parser thinks
|
|
# the command has no single pipe and never flags.
|
|
verify_mutation "single-pipe detection" \
|
|
'if c == "|" and i + 1 < len(cmd) and cmd[i + 1] == "|":=>if c == "|" or i + 1 < len(cmd) and cmd[i + 1] == "|":' \
|
|
'git push -q origin main 2>&1 | tail -1 && echo "pushed"' \
|
|
"flag"
|
|
|
|
# Mutation D: drop the curl-with-write branch so a state-changing curl stops
|
|
# being flagged.
|
|
verify_mutation "curl write-method detection" \
|
|
'verb = "curl"=>verb = "WEB' \
|
|
'curl -X POST https://api.example.com | jq && echo done' \
|
|
"flag"
|
|
|
|
# Mutation E: kill the `then` check so the if-context case stops being flagged.
|
|
verify_mutation "then-keyword check" \
|
|
're.search(r"\bthen\b", rest)=>re.search(r"XXXXX", rest)' \
|
|
'if git push origin main | head; then echo done; fi' \
|
|
"flag"
|
|
|
|
# Mutation F: kill the PIPESTATUS bailout so a should-pass command with
|
|
# PIPESTATUS now incorrectly flags.
|
|
verify_mutation "PIPESTATUS bailout" \
|
|
're.search(r"\$\{?PIPESTATUS", cmd)=>re.search(r"\$\{?XXXXXXXX", cmd)' \
|
|
'git push | head; echo ${PIPESTATUS[0]} && echo done' \
|
|
"no-flag"
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Summary
|
|
# -----------------------------------------------------------------------------
|
|
|
|
echo
|
|
echo "Passed: $PASS_COUNT"
|
|
echo "Failed: $FAIL_COUNT"
|
|
|
|
if [ "$FAIL" -eq 0 ]; then
|
|
echo "ALL_OK"
|
|
exit 0
|
|
else
|
|
exit 1
|
|
fi |