Compare commits
1 Commits
wip/hooks-
...
wip/hooks-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a76ba0e3 |
127
formatters/sh
127
formatters/sh
@@ -1,136 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Formatter: Shell — shfmt + shellcheck + SETE/LEAK checks
|
||||
# Formatter: Shell — shfmt + shellcheck
|
||||
# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors
|
||||
#
|
||||
# SETE-1: shebanged script with no `set -e` / `set -euo pipefail` anywhere
|
||||
# SETE-2: sh/bash -c string with 2+ ;- or newline-separated commands and no set -e inside
|
||||
# LEAK-1: command substitution reading a secret-named file interpolated into
|
||||
# echo/printf/die/error/fail argument
|
||||
#
|
||||
# Missing shfmt AND shellcheck: emit one warning to stderr, still exit 0
|
||||
# (a missing tool must not block an edit). Any SETE/LEAK finding: print
|
||||
# "path:line: ID message" to stderr, exit non-zero.
|
||||
|
||||
set -uo pipefail
|
||||
# Missing tools: exit 0 silently
|
||||
|
||||
FILE="$1"
|
||||
[ -z "$FILE" ] && exit 0
|
||||
[ -f "$FILE" ] || exit 0
|
||||
|
||||
HAD_ERRORS=0
|
||||
HAS_SHFMT=0
|
||||
HAS_SHELLCHECK=0
|
||||
|
||||
# Format with shfmt if available
|
||||
if command -v shfmt >/dev/null 2>&1; then
|
||||
HAS_SHFMT=1
|
||||
shfmt -w "$FILE" 2>/dev/null
|
||||
shfmt -w "$FILE" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Lint with shellcheck if available
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
HAS_SHELLCHECK=1
|
||||
if ! ERRORS=$(shellcheck -f gcc "$FILE" 2>&1); then
|
||||
echo "$ERRORS" >&2
|
||||
HAD_ERRORS=1
|
||||
fi
|
||||
ERRORS=$(shellcheck -f gcc "$FILE" 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "$ERRORS" >&2
|
||||
HAD_ERRORS=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Custom checks: SETE-1, SETE-2, LEAK-1 ---
|
||||
# Always run, regardless of shfmt/shellcheck presence. Reads $FILE, emits
|
||||
# one "path:line: ID message" line per finding to stdout, exits non-zero on
|
||||
# any finding.
|
||||
|
||||
if ! CUSTOM_OUT=$(
|
||||
python3 - "$FILE" <<'PYEOF'
|
||||
import re
|
||||
import sys
|
||||
|
||||
filepath = sys.argv[1]
|
||||
findings = []
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
lines = content.splitlines()
|
||||
except OSError:
|
||||
sys.exit(0)
|
||||
|
||||
# SETE-1: shebanged script with no `set -e` / `set -euo pipefail` anywhere
|
||||
first = lines[0] if lines else ''
|
||||
if first.startswith('#!'):
|
||||
has_sete = False
|
||||
for line in lines:
|
||||
if re.search(r'(^|[^A-Za-z0-9_])set\s+-[A-Za-z]*[eE]', line):
|
||||
has_sete = True
|
||||
break
|
||||
if not has_sete:
|
||||
findings.append((1, "SETE-1 executable shell script without 'set -e'; failed earlier commands will not abort"))
|
||||
|
||||
SECRET_RE = re.compile(r'TOKEN|SECRET|KEY|PASSWORD|_JSON_FILE|CREDENTIAL', re.IGNORECASE)
|
||||
SINK_RE = re.compile(r'\b(echo|printf|die|error|fail)(?=\s|\()')
|
||||
FILE_READ_RE = re.compile(
|
||||
r'(\$\(|`)\s*(cat|<)\s*"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"\s*(\)|`)'
|
||||
)
|
||||
|
||||
def count_top_level_commands(s):
|
||||
"""Count ;- or newline-separated top-level commands in a shell string,
|
||||
ignoring separators inside single or double quotes."""
|
||||
in_single = False
|
||||
in_double = False
|
||||
separators = 0
|
||||
for c in s:
|
||||
if not in_double and c == "'":
|
||||
in_single = not in_single
|
||||
elif not in_single and c == '"':
|
||||
in_double = not in_double
|
||||
elif c in ';\n' and not in_single and not in_double:
|
||||
separators += 1
|
||||
return separators + 1
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# LEAK-1: command substitution reading a secret-named file, inside echo/printf/die/error/fail
|
||||
sink_match = SINK_RE.search(line)
|
||||
if sink_match:
|
||||
cmd = sink_match.group(1)
|
||||
for cmdsub in FILE_READ_RE.finditer(line):
|
||||
var = cmdsub.group(3)
|
||||
if SECRET_RE.search(var):
|
||||
findings.append((i, "LEAK-1 secret variable '$" + var + "' read into string passed to '" + cmd + "'; could print credential document"))
|
||||
|
||||
# SETE-2 runs over the WHOLE source, not line by line: the shape that motivated
|
||||
# this check spans lines —
|
||||
# sh -c "
|
||||
# hugo mod get ...
|
||||
# hugo mod tidy
|
||||
# "
|
||||
# — so a per-line regex requiring both quotes on one line never matched it.
|
||||
for m in re.finditer(r'\b(?:sh|bash)\s+-c\s+(["\'])(.*?)\1', content, re.DOTALL):
|
||||
body = m.group(2)
|
||||
if re.search(r'(^|[^A-Za-z0-9_])set\s+-[A-Za-z]*[eE]', body):
|
||||
continue
|
||||
n = count_top_level_commands(body)
|
||||
if n < 2:
|
||||
n = len([ln for ln in body.splitlines() if ln.strip() and not ln.strip().startswith('#')])
|
||||
if n >= 2:
|
||||
line_no = content[:m.start()].count('\n') + 1
|
||||
findings.append((line_no, "SETE-2 sh/bash -c with " + str(n) + " commands and no 'set -e' inside; exit status reflects only the last command"))
|
||||
|
||||
for line_no, msg in findings:
|
||||
print(filepath + ":" + str(line_no) + ": " + msg)
|
||||
|
||||
sys.exit(1 if findings else 0)
|
||||
PYEOF
|
||||
); then
|
||||
echo "$CUSTOM_OUT" >&2
|
||||
HAD_ERRORS=1
|
||||
fi
|
||||
|
||||
# If neither shfmt nor shellcheck is installed, warn ONCE so the silent pass
|
||||
# is no longer silent. A missing tool must not block an edit (would be a worse
|
||||
# bug than the one this warning surfaces).
|
||||
if [ "$HAS_SHFMT" -eq 0 ] && [ "$HAS_SHELLCHECK" -eq 0 ]; then
|
||||
echo "formatters/sh: shfmt and shellcheck not installed; install via 'apt install shfmt shellcheck' (or equivalent). Custom checks still ran." >&2
|
||||
# If neither tool is installed, silently pass
|
||||
if ! command -v shfmt >/dev/null 2>&1 && ! command -v shellcheck >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit $HAD_ERRORS
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/test-sh-formatter.sh — assert formatters/sh runs SETE-1/2 and LEAK-1
|
||||
#
|
||||
# House convention: bash, set -uo pipefail, SKIP-and-exit-0 when a tool is
|
||||
# missing, one PASS:/FAIL: line per assertion, FAIL=1 aggregate, ALL_OK on
|
||||
# full pass.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FORMATTER="$SCRIPT_DIR/../sh"
|
||||
|
||||
# --- SKIP gates: missing tool → exit 0 silently ---
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "SKIP: python3 not on PATH — formatters/sh custom checks need it"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -x "$FORMATTER" ]; then
|
||||
echo "SKIP: formatters/sh not found or not executable at $FORMATTER"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() {
|
||||
printf "PASS: %s\n" "$1"
|
||||
PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf "FAIL: %s\n" "$1"
|
||||
[[ -n "${2:-}" ]] && printf " %s\n" "$2"
|
||||
FAIL=$((FAIL + 1))
|
||||
}
|
||||
|
||||
TMPDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
# Write a fixture and run the formatter. Assert:
|
||||
# - exit code matches expected_exit
|
||||
# - stderr contains the pattern (when expect_pattern is set)
|
||||
assert_flagged() {
|
||||
local label="$1"
|
||||
local expected_exit="$2"
|
||||
local pattern="$3"
|
||||
local content="$4"
|
||||
local f="$TMPDIR/$(printf '%s' "$label" | tr ' /' '__').sh"
|
||||
|
||||
printf '%s\n' "$content" > "$f"
|
||||
|
||||
local stderr_file="$TMPDIR/$(basename "$f").stderr"
|
||||
"$FORMATTER" "$f" >/dev/null 2>"$stderr_file"
|
||||
local actual_exit=$?
|
||||
local stderr_content
|
||||
stderr_content="$(cat "$stderr_file")"
|
||||
|
||||
if [ "$actual_exit" -ne "$expected_exit" ]; then
|
||||
fail "$label (exit)" "expected $expected_exit, got $actual_exit. stderr: $stderr_content"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$pattern" ] && ! echo "$stderr_content" | grep -qE "$pattern"; then
|
||||
fail "$label (pattern)" "expected pattern '$pattern', got: $stderr_content"
|
||||
return
|
||||
fi
|
||||
|
||||
pass "$label"
|
||||
}
|
||||
|
||||
# Write a fixture and run the formatter. Assert:
|
||||
# - exit code is 0
|
||||
# - stderr does NOT contain the forbidden pattern
|
||||
assert_clean() {
|
||||
local label="$1"
|
||||
local forbidden="$2"
|
||||
local content="$3"
|
||||
local f="$TMPDIR/$(printf '%s' "$label" | tr ' /' '__').sh"
|
||||
|
||||
printf '%s\n' "$content" > "$f"
|
||||
|
||||
local stderr_file="$TMPDIR/$(basename "$f").stderr"
|
||||
"$FORMATTER" "$f" >/dev/null 2>"$stderr_file"
|
||||
local actual_exit=$?
|
||||
local stderr_content
|
||||
stderr_content="$(cat "$stderr_file")"
|
||||
|
||||
if [ "$actual_exit" -ne 0 ]; then
|
||||
fail "$label (exit)" "expected 0, got $actual_exit. stderr: $stderr_content"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$forbidden" ] && echo "$stderr_content" | grep -qE "$forbidden"; then
|
||||
fail "$label (forbidden pattern)" "should NOT match '$forbidden', got: $stderr_content"
|
||||
return
|
||||
fi
|
||||
|
||||
pass "$label"
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
echo "=== SETE-1: shebanged script without set -e ==="
|
||||
# ===========================================================================
|
||||
|
||||
# Positive: shebang, no set -e anywhere → must flag SETE-1, exit 1
|
||||
assert_flagged "SETE-1 positive" 1 "SETE-1" \
|
||||
"#!/usr/bin/env bash
|
||||
echo hello
|
||||
ls /nonexistent
|
||||
echo world"
|
||||
|
||||
# Negative: shebang + set -euo pipefail → no SETE-1 finding, exit 0
|
||||
assert_clean "SETE-1 negative (has set -euo pipefail)" "SETE-1" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo hello
|
||||
ls /nonexistent || true"
|
||||
|
||||
# Negative: shebang + set -e (short form) → no SETE-1 finding, exit 0
|
||||
assert_clean "SETE-1 negative (has set -e)" "SETE-1" \
|
||||
"#!/usr/bin/env bash
|
||||
set -e
|
||||
echo hello"
|
||||
|
||||
# Negative: sourced fragment (no shebang) without set -e → no SETE-1 finding
|
||||
# `shell=bash` directive satisfies shellcheck's SC2148 ("add a shebang or shell directive")
|
||||
assert_clean "SETE-1 negative (sourced fragment, no shebang)" "SETE-1" \
|
||||
"# shellcheck shell=bash
|
||||
echo hello
|
||||
ls /nonexistent || true
|
||||
echo world"
|
||||
|
||||
# ===========================================================================
|
||||
echo ""
|
||||
echo "=== SETE-2: sh/bash -c with multi-command string and no set -e ==="
|
||||
# ===========================================================================
|
||||
|
||||
# Positive: sh -c with two ;-separated commands and no set -e → flag SETE-2
|
||||
assert_flagged "SETE-2 positive (hugo mod get; mod tidy)" 1 "SETE-2" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
sh -c \"hugo mod get github.com/foo/bar; hugo mod tidy\""
|
||||
|
||||
# Positive: bash -c with multiple ; commands, no set -e
|
||||
assert_flagged "SETE-2 positive (bash -c cmd1; cmd2)" 1 "SETE-2" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
bash -c \"echo a; echo b; echo c\""
|
||||
|
||||
# Negative: sh -c with single command → no SETE-2 finding
|
||||
assert_clean "SETE-2 negative (single command)" "SETE-2" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
sh -c \"hugo mod tidy\""
|
||||
|
||||
# Negative: sh -c with multi commands BUT set -e inside → no SETE-2 finding
|
||||
assert_clean "SETE-2 negative (has set -e inside)" "SETE-2" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
sh -c \"set -e; hugo mod get; hugo mod tidy\""
|
||||
|
||||
# ===========================================================================
|
||||
echo ""
|
||||
echo "=== LEAK-1: secret-named file read into echo/printf/die/error/fail ==="
|
||||
# ===========================================================================
|
||||
|
||||
# Positive: die "$(cat "$KV_JSON_FILE")" — the canonical real case from the brief
|
||||
assert_flagged "LEAK-1 positive (die with cat \$KV_JSON_FILE)" 1 "LEAK-1" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
die \"kv put failed: \$(cat \"\$KV_JSON_FILE\")\""
|
||||
|
||||
# Positive: echo with TOKEN-named file read
|
||||
assert_flagged "LEAK-1 positive (echo with \$GITHUB_TOKEN_FILE)" 1 "LEAK-1" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo \"got: \$(cat \"\$GITHUB_TOKEN_FILE\")\""
|
||||
|
||||
# Positive: printf with PASSWORD-named file read
|
||||
assert_flagged "LEAK-1 positive (printf with \$DB_PASSWORD_FILE)" 1 "LEAK-1" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'db password was: %s\n' \"\$(< \"\$DB_PASSWORD_FILE\")\""
|
||||
|
||||
# Negative: die with no secret-named variable
|
||||
assert_clean "LEAK-1 negative (regular error)" "LEAK-1" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
die \"kv put failed: connection refused\""
|
||||
|
||||
# Negative: die with secret-named var but NOT read via cat/$() — just interpolated
|
||||
assert_clean "LEAK-1 negative (var interpolated, not cat-substituted)" "LEAK-1" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
RESULT_VAR=foo
|
||||
die \"error code: \$RESULT_VAR\""
|
||||
|
||||
# Negative: secret-named var read via cat but NOT in echo/die/etc.
|
||||
# Mark vars as used via export so shellcheck doesn't flag SC2034.
|
||||
assert_clean "LEAK-1 negative (secret file read, not in echo)" "LEAK-1" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
contents=\$(cat \"\$API_TOKEN_FILE\")
|
||||
export contents"
|
||||
|
||||
# Negative: echo with non-secret variable name
|
||||
assert_clean "LEAK-1 negative (echo with non-secret var)" "LEAK-1" \
|
||||
"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo \"result file content: \$(cat \"\$RESULT_FILE\")\""
|
||||
|
||||
# ===========================================================================
|
||||
echo ""
|
||||
echo "=== Missing-tool warning (formatters/sh must NOT silently pass) ==="
|
||||
# ===========================================================================
|
||||
|
||||
# When neither tool is on PATH, formatter should emit ONE warning line.
|
||||
# Build a PATH that has bash + python3 but no shfmt or shellcheck, then run.
|
||||
PATH_CLEAN="$(mktemp -d)"
|
||||
ln -sf /usr/bin/bash "$PATH_CLEAN/bash"
|
||||
ln -sf /usr/bin/python3 "$PATH_CLEAN/python3"
|
||||
stderr_file="$TMPDIR/no-tools.stderr"
|
||||
# Use a clean fixture — must not flag any SETE/LEAK.
|
||||
printf '#!/usr/bin/env bash\nset -euo pipefail\necho hello\n' > "$TMPDIR/clean-fixture.sh"
|
||||
PATH="$PATH_CLEAN" bash "$FORMATTER" "$TMPDIR/clean-fixture.sh" >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
stderr_content="$(cat "$stderr_file")"
|
||||
rm -rf "$PATH_CLEAN"
|
||||
|
||||
if [ "$rc" -eq 0 ] && echo "$stderr_content" | grep -qE 'formatters/sh: shfmt and shellcheck not installed'; then
|
||||
pass "missing-tool warning emitted, exit 0"
|
||||
else
|
||||
fail "missing-tool warning" "expected exit 0 + warning line, got exit=$rc stderr=$stderr_content"
|
||||
fi
|
||||
|
||||
# SETE-2 must see a MULTI-LINE `sh -c "..."`. That is the shape of the real
|
||||
# sync-deps bug; the original per-line regex required both quotes on one line
|
||||
# and missed it entirely.
|
||||
f="$TMPDIR/sete2-multiline.sh"
|
||||
printf '#!/usr/bin/env bash\nset -euo pipefail\ndocker run --rm img sh -c "\n hugo mod get a b c\n hugo mod tidy\n"\n' > "$f"
|
||||
out="$(bash "$FORMATTER" "$f" 2>&1)"
|
||||
if printf '%s' "$out" | grep -q "SETE-2"; then
|
||||
pass "SETE-2 flags a multi-line sh -c with no set -e"
|
||||
else
|
||||
fail "SETE-2 MISSED the multi-line sh -c form (the real sync-deps bug); got: $out"
|
||||
fi
|
||||
|
||||
f="$TMPDIR/sete2-multiline-ok.sh"
|
||||
printf '#!/usr/bin/env bash\nset -euo pipefail\ndocker run --rm img sh -c "\n set -e\n hugo mod get a b c\n hugo mod tidy\n"\n' > "$f"
|
||||
out="$(bash "$FORMATTER" "$f" 2>&1)"
|
||||
if printf '%s' "$out" | grep -q "SETE-2"; then
|
||||
fail "SETE-2 false-positived on a multi-line sh -c that DOES set -e"
|
||||
else
|
||||
pass "SETE-2 quiet when the multi-line sh -c sets -e"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "ALL_OK"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
140
hooks/bash-pipeline-guard.sh
Executable file
140
hooks/bash-pipeline-guard.sh
Executable file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook for Bash — flags pipelines whose left side changes state but
|
||||
# whose exit status is then consumed by `&&`, `||`, or an `if`-condition.
|
||||
#
|
||||
# bug or subtlety: `git push -q origin main 2>&1 | tail -1 && echo "pushed"`
|
||||
# reads tail's exit status, not git's. A push that was rejected
|
||||
# as non-fast-forward still reports "pushed".
|
||||
#
|
||||
# Contract:
|
||||
# - exit 0 for anything it does not understand, and for any parse failure.
|
||||
# A hook that breaks Bash is far worse than the bug it prevents.
|
||||
# - exit 2 with a short stderr explanation when it sees the pattern.
|
||||
# Advisory only — never block.
|
||||
#
|
||||
# Reads tool_input.command from stdin JSON (same shape post-edit-lint.sh parses).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT="$(cat 2>/dev/null || true)"
|
||||
[ -n "$INPUT" ] || exit 0
|
||||
command -v python3 >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Inline the matcher as a python script in a temp file. Single-quoted heredoc
|
||||
# prevents shell expansion of $-characters in the python source.
|
||||
TMPPY="$(mktemp -t bpg.XXXXXX 2>/dev/null || mktemp -t bpg.XXXXXX)" || exit 0
|
||||
[ -n "$TMPPY" ] || exit 0
|
||||
trap 'rm -f "$TMPPY"' EXIT INT TERM HUP
|
||||
|
||||
cat >"$TMPPY" <<'PYTHON_EOF'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
# Parse failure → silent pass
|
||||
sys.exit(0)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
sys.exit(0)
|
||||
|
||||
tool_input = data.get("tool_input")
|
||||
if not isinstance(tool_input, dict):
|
||||
sys.exit(0)
|
||||
|
||||
cmd = tool_input.get("command", "")
|
||||
if not isinstance(cmd, str) or not cmd:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def strip_quotes(s):
|
||||
# Strip double-quoted and single-quoted strings so we don't match a verb
|
||||
# that lives inside a quoted argument or echo payload.
|
||||
s = re.sub(r'"[^"]*"', "", s)
|
||||
s = re.sub(r"'[^']*'", "", s)
|
||||
return s
|
||||
|
||||
|
||||
# Author has handled the masked-status problem → no need to flag.
|
||||
if re.search(r"set\s+-o\s+pipefail", cmd):
|
||||
sys.exit(0)
|
||||
if re.search(r"\$\{?PIPESTATUS", cmd):
|
||||
sys.exit(0)
|
||||
|
||||
# Walk the command once and record single-pipe positions, skipping `||`.
|
||||
pipes = []
|
||||
i = 0
|
||||
while i < len(cmd):
|
||||
c = cmd[i]
|
||||
if c == "|" and i + 1 < len(cmd) and cmd[i + 1] == "|":
|
||||
i += 2
|
||||
continue
|
||||
if c == "|":
|
||||
pipes.append(i)
|
||||
i += 1
|
||||
|
||||
if not pipes:
|
||||
sys.exit(0)
|
||||
|
||||
# Left side = everything before the first pipe (the segment whose status we
|
||||
# care about).
|
||||
left = strip_quotes(cmd[: pipes[0]])
|
||||
|
||||
# State-changing verbs. Matched conservatively — these are the verbs the brief
|
||||
# enumerates, matched as `verb` (not as a prefix).
|
||||
state_verbs = [
|
||||
(r"\bgit\s+push\b", "git push"),
|
||||
(r"\bgit\s+commit\b", "git commit"),
|
||||
(r"\bgit\s+merge\b", "git merge"),
|
||||
(r"\bgit\s+rebase\b", "git rebase"),
|
||||
(r"\bkubectl\s+apply\b", "kubectl apply"),
|
||||
(r"\bkubectl\s+delete\b", "kubectl delete"),
|
||||
(r"\bkubectl\s+patch\b", "kubectl patch"),
|
||||
(r"\bdocker\s+push\b", "docker push"),
|
||||
(r"\bhelm\s+upgrade\b", "helm upgrade"),
|
||||
(r"\bhelm\s+install\b", "helm install"),
|
||||
]
|
||||
verb = None
|
||||
for pat, name in state_verbs:
|
||||
if re.search(pat, left):
|
||||
verb = name
|
||||
break
|
||||
|
||||
# curl is special: only state-changing when paired with a write method or a
|
||||
# data payload. Read-only `curl ... | jq` must not be flagged.
|
||||
if not verb and re.search(r"\bcurl\b", left):
|
||||
if re.search(r"-X\s*(POST|PUT|DELETE)", left, re.IGNORECASE) or re.search(
|
||||
r"--data", left
|
||||
):
|
||||
verb = "curl"
|
||||
|
||||
if not verb:
|
||||
sys.exit(0)
|
||||
|
||||
# Is the pipeline's status consumed by `&&`, `||`, or an `if`-test whose
|
||||
# `then` clause follows the pipeline?
|
||||
rest = cmd[pipes[-1] + 1 :]
|
||||
if re.search(r"&&|\|\|", rest):
|
||||
print(verb)
|
||||
sys.exit(0)
|
||||
if re.search(r"\bthen\b", rest):
|
||||
print(verb)
|
||||
sys.exit(0)
|
||||
|
||||
sys.exit(0)
|
||||
PYTHON_EOF
|
||||
|
||||
# Pipe the raw hook input through the python matcher; capture stdout (the verb
|
||||
# name to surface in the advisory). Any non-zero exit from python (e.g. a real
|
||||
# crash) is treated as a silent pass — same policy as a parse failure.
|
||||
RESULT="$(printf '%s' "$INPUT" | python3 "$TMPPY" 2>/dev/null)" || exit 0
|
||||
[ -n "$RESULT" ] || exit 0
|
||||
|
||||
{
|
||||
printf 'Pipeline masks %s exit status: &&/|| reads the LAST command in the pipe, not the first. Use ${PIPESTATUS[0]} or drop the pipe.\n' \
|
||||
"$RESULT"
|
||||
} >&2
|
||||
exit 2
|
||||
338
hooks/tests/test-bash-pipeline-guard.sh
Executable file
338
hooks/tests/test-bash-pipeline-guard.sh
Executable file
@@ -0,0 +1,338 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user