Compare commits

...
2 Commits
Author SHA1 Message Date
mathias a42ec18f40 fix(secret-guard): treat env as a dump only when no command follows
CI / Tag release (push) Successful in 1s
CI / Check / Test (push) Successful in 1s
`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
mathias 46ff1acdfe chore(release): plugin.json version 0.8.0
Skip-Release: true
2026-08-29 07:47:07 +00:00
3 changed files with 24 additions and 6 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "skills", "name": "skills",
"description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.", "description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.",
"version": "0.7.0", "version": "0.8.0",
"author": { "author": {
"name": "Mathias", "name": "Mathias",
"url": "https://git.d-ma.be/mathias" "url": "https://git.d-ma.be/mathias"
+12 -5
View File
@@ -50,11 +50,18 @@ if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+document\s+get\b'; then
fi fi
# Raw env/secret-file dumps (infra#228/#229 root cause). # Raw env/secret-file dumps (infra#228/#229 root cause).
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)env(\s|$).*\|.*grep'; then #
deny "Blocked: 'env | grep' prints matching values in full. Use an existence-only check: [ -n \"\$VAR\" ] && echo set" # `env` is a dump only when nothing but options and VAR=value assignments
fi # follow it. `env -u VAR cmd` and `env FOO=bar cmd` EXEC a command with a
if printf '%s' "$CMD" | grep -Eq '(^|[;&|])[[:space:]]*env[[:space:]]*($|[;&|])'; then # modified environment and print nothing themselves — piping those into grep
deny "Blocked: bare 'env' prints every variable's value, including secrets. Use an existence-only check: [ -n \"\$VAR\" ] && echo set" # filters the command's own output, not the environment (#25). The earlier
# pattern matched any `env` token followed anywhere later by a pipe into
# grep, which blocked the documented 1Password write path
# (`env -u OP_SERVICE_ACCOUNT_TOKEN op item create`) and cost a workaround.
# Denying only the "no command word follows" shape also catches `env FOO=bar`
# with no command, which the two previous rules let through.
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|[[:space:]])env([[:space:]]+-[iu][[:space:]]*[A-Za-z_][A-Za-z0-9_]*|[[:space:]]+-[iu]|[[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*)*[[:space:]]*($|[|;&])'; then
deny "Blocked: this 'env' prints every variable's value, including secrets (no command word follows it, so it dumps rather than execs). Use an existence-only check: [ -n \"\$VAR\" ] && echo set. Note 'env -u VAR cmd' and 'env FOO=bar cmd' are allowed — they run a command."
fi fi
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)printenv\b'; then if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)printenv\b'; then
deny "Blocked: printenv dumps all env values. Use: [ -n \"\$VAR\" ] && echo set" deny "Blocked: printenv dumps all env values. Use: [ -n \"\$VAR\" ] && echo set"
+11
View File
@@ -61,6 +61,17 @@ expect deny 'op read "op://vault/item/field"' 'op read'
expect deny 'env | grep -i gitea' 'env piped to grep' expect deny 'env | grep -i gitea' 'env piped to grep'
expect deny 'env' 'bare env' expect deny 'env' 'bare env'
expect deny 'printenv' 'printenv' 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 --- # --- deny: kubernetes secret material ---
expect deny 'kubectl get secret brain-app -o yaml' 'kubectl get secret -o yaml' expect deny 'kubectl get secret brain-app -o yaml' 'kubectl get secret -o yaml'