Files
skills/tests/secret_guard_test.sh
T
mathias a42ec18f40
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
fix(secret-guard): treat env as a dump only when no command follows
`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
2026-08-29 09:50:08 +02:00

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 ]