`env -u VAR cmd` and `env FOO=bar cmd` exec a command with a modified environment and print nothing themselves. The old rule matched any `env` token followed anywhere later by a pipe into a filter, so it blocked the documented 1Password write path and forced a `(unset VAR; cmd)` workaround. False positives only — never a leak — but a guard people route around is a guard on its way out. The rule now denies only when everything after `env` is exhausted by options and VAR=value assignments. That is strictly the dumping shape, and it also closes a hole the two previous rules left open: `env FOO=bar` with no command word prints the whole environment and was allowed. Three new deny/allow pairs pin it, including the 1Password write shape that motivated the report. 20 cases now, up from 14, all passing. Closes #25 Bump-Type: patch
98 lines
3.7 KiB
Bash
98 lines
3.7 KiB
Bash
#!/bin/sh
|
|
# Tests for hooks/secret-guard.sh — the PreToolUse gate that blocks command
|
|
# shapes which render secret values into the transcript.
|
|
#
|
|
# Contract: reads PreToolUse hook JSON on stdin, ALWAYS exits 0, and answers
|
|
# in the JSON body as hookSpecificOutput.permissionDecision = allow | deny.
|
|
# Asserting on exit status would pass vacuously — assert on the decision.
|
|
#
|
|
# Every deny case below is a shape that caused a real leak; the comment names
|
|
# the incident so nobody "simplifies" the pattern away later.
|
|
|
|
set -u
|
|
|
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|
GUARD="$ROOT/hooks/secret-guard.sh"
|
|
|
|
pass=0
|
|
fail=0
|
|
|
|
# decision_for <command-string> [tool_name]
|
|
# Emits the guard's permissionDecision for a synthetic Bash tool call.
|
|
decision_for() {
|
|
_cmd=$1
|
|
_tool=${2:-Bash}
|
|
jq -n --arg t "$_tool" --arg c "$_cmd" \
|
|
'{tool_name: $t, tool_input: {command: $c}}' \
|
|
| sh "$GUARD" \
|
|
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
|
|
}
|
|
|
|
expect() {
|
|
_want=$1
|
|
_cmd=$2
|
|
_label=$3
|
|
_got=$(decision_for "$_cmd")
|
|
if [ "$_got" = "$_want" ]; then
|
|
pass=$((pass + 1))
|
|
printf ' ok %s\n' "$_label"
|
|
else
|
|
fail=$((fail + 1))
|
|
printf ' FAIL %s\n want=%s got=%s\n cmd=%s\n' "$_label" "$_want" "$_got" "$_cmd"
|
|
fi
|
|
}
|
|
|
|
if [ ! -f "$GUARD" ]; then
|
|
printf 'FAIL: %s does not exist\n' "$GUARD"
|
|
exit 1
|
|
fi
|
|
|
|
command -v jq >/dev/null 2>&1 || { printf 'SKIP: jq not installed\n'; exit 0; }
|
|
|
|
# --- deny: decrypt-and-print in one invocation (2026-08-05 ntfy-token leak) ---
|
|
expect deny 'sops -d secrets.enc.yaml | tail -20' 'sops decrypt piped to tail'
|
|
expect deny 'sops -d ingress.enc.yaml > /tmp/x; cat /tmp/x' 'sops decrypt then cat'
|
|
|
|
# --- deny: 1Password raw reads (2026-07-30) ---
|
|
expect deny 'op item get "gitea mcp" --format json' 'op item get'
|
|
expect deny 'op read "op://vault/item/field"' 'op read'
|
|
|
|
# --- deny: env dumps (infra#228, 2026-07-27) ---
|
|
expect deny 'env | grep -i gitea' 'env piped to grep'
|
|
expect deny 'env' 'bare env'
|
|
expect deny 'printenv' 'printenv'
|
|
expect deny 'env -i | grep -i token' 'env -i with no command is still a dump'
|
|
expect deny 'env FOO=bar' 'env with only assignments and no command is a dump'
|
|
|
|
# --- allow: `env VAR=x cmd` / `env -u VAR cmd` RUN a command (#25) ---
|
|
# These print nothing themselves; a pipe into grep filters the command's own
|
|
# output, not the environment. Blocking them cost a documented workaround
|
|
# ((unset VAR; cmd)) on the 1Password write path, which is the shape below.
|
|
expect allow 'env -u ANTHROPIC_API_KEY claude --debug -p "ok" 2>&1 | grep -i plugin' 'env -u VAR runs a command'
|
|
expect allow 'env FOO=bar ./script.sh | grep x' 'env VAR=x runs a command'
|
|
expect allow 'env -u OP_SERVICE_ACCOUNT_TOKEN op item create - < item.json' 'env -u on the 1P write path'
|
|
expect deny 'env -u FOO ./run.sh; env | grep -i secret' 'a real dump later in the same line still denies'
|
|
|
|
# --- deny: kubernetes secret material ---
|
|
expect deny 'kubectl get secret brain-app -o yaml' 'kubectl get secret -o yaml'
|
|
|
|
# --- deny: base64 as a masking bypass (2026-06-11) ---
|
|
expect deny 'echo "$GITEA_MCP_TOKEN" | base64' 'token piped through base64'
|
|
|
|
# --- allow: ordinary commands must not be blocked ---
|
|
expect allow 'ls -la /tmp' 'plain ls'
|
|
expect allow 'git status --porcelain' 'git status'
|
|
expect allow 'kubectl describe secret brain-app' 'kubectl describe secret (values redacted by k8s)'
|
|
expect allow '[ -n "$GITEA_MCP_TOKEN" ] && echo set' 'existence-only check'
|
|
|
|
# --- allow: the guard only judges Bash ---
|
|
_got=$(decision_for 'env' 'Read')
|
|
if [ "$_got" = "allow" ]; then
|
|
pass=$((pass + 1)); printf ' ok non-Bash tool passes through\n'
|
|
else
|
|
fail=$((fail + 1)); printf ' FAIL non-Bash tool passes through\n want=allow got=%s\n' "$_got"
|
|
fi
|
|
|
|
printf '\nsecret_guard: %d passed, %d failed\n' "$pass" "$fail"
|
|
[ "$fail" -eq 0 ]
|