mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(bash-guard): deny git verbs hidden in command substitutions (#226)
Closes the real bypass antfleet flagged in PR #223 (credit to them for the finding): a denied git verb inside $(...) or backticks is expanded by the shell before the wrapping echo/printf runs, so the skeletonizer's strip hid it from the git check. This reworks it correctly where #223's fix could not land: - targets the live source (docker/scripts/bash-guard-hook.sh, COPY'd to /app/scripts/), not a path that doesn't exist; - runs INSIDE the ROBOCO_GUARD_SKIP_GIT guard, so on grok it stays the native --deny's job and never hard-cancels the run (#223 ran it unconditionally); - excludes single-quoted strings and heredoc bodies (literal / data, matching the skeletonizer), so a README documenting git verbs isn't a false positive; - fails closed (a non-sentinel / python failure denies). 44 bash-guard tests pass (5 new: dollar/backtick/double-quoted substitution deny, single-quoted literal allow, grok-skip allow). Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -100,6 +100,64 @@ if [[ "${ROBOCO_GUARD_SKIP_GIT:-}" != "1" ]] && \
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- git verbs hidden in command substitutions ($(...) / backticks) ----------
|
||||||
|
# The skeletonizer above strips echo/printf/heredoc DATA, but a command
|
||||||
|
# substitution inside that data is EXPANDED by the shell before the wrapping
|
||||||
|
# command runs — so `echo $(git push)` would slip past the git check above.
|
||||||
|
# Detect a denied git verb inside an EXPANDABLE substitution. Single-quoted
|
||||||
|
# strings (literal) and heredoc bodies (treated as data, matching the
|
||||||
|
# skeletonizer above) are excluded so a README that merely documents git verbs
|
||||||
|
# is not a false positive — this targets the echo/printf substitution class
|
||||||
|
# (`echo $(git push)`). Same SKIP_GIT guard as the check above — on grok this
|
||||||
|
# stays the native --deny's job, never a run-cancelling hook deny.
|
||||||
|
if [[ "${ROBOCO_GUARD_SKIP_GIT:-}" != "1" ]]; then
|
||||||
|
subst_git=$(printf '%s' "$cmd" | python3 -c '
|
||||||
|
import sys, re
|
||||||
|
src = sys.stdin.read()
|
||||||
|
q = chr(39)
|
||||||
|
lines = src.split(chr(10))
|
||||||
|
opener = re.compile(r"<<-?\s*[^\sA-Za-z_]*([A-Za-z_]\w*)")
|
||||||
|
kept = []
|
||||||
|
i = 0
|
||||||
|
n = len(lines)
|
||||||
|
while i < n:
|
||||||
|
line = lines[i]
|
||||||
|
kept.append(line)
|
||||||
|
m = opener.search(line)
|
||||||
|
if m:
|
||||||
|
delim = m.group(1)
|
||||||
|
dash = "<<-" in line
|
||||||
|
i += 1
|
||||||
|
while i < n:
|
||||||
|
cand = lines[i].strip() if dash else lines[i]
|
||||||
|
if cand == delim:
|
||||||
|
kept.append(lines[i])
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
i += 1
|
||||||
|
text = chr(10).join(kept)
|
||||||
|
text = re.sub(q + "[^" + q + "]*" + q, " ", text)
|
||||||
|
bt = chr(96)
|
||||||
|
verbs = r"(fetch|pull|push|clone|remote|ls-remote|checkout|commit|merge|rebase|reset|cherry-pick|revert|tag\s+-d|update-ref|reflog\s+delete)"
|
||||||
|
gitre = re.compile(r"(^|[\s;&|()$" + bt + r"])git\s+" + verbs, re.IGNORECASE)
|
||||||
|
subst = re.compile(r"\$\(([^()]*(?:\([^()]*\)[^()]*)*)\)|" + bt + r"([^" + bt + r"]*)" + bt, re.DOTALL)
|
||||||
|
deny = "no"
|
||||||
|
for m in subst.finditer(text):
|
||||||
|
inner = m.group(1) or m.group(2) or ""
|
||||||
|
if gitre.search(inner):
|
||||||
|
deny = "yes"
|
||||||
|
break
|
||||||
|
sys.stdout.write("__OK__" + deny)
|
||||||
|
' 2>/dev/null)
|
||||||
|
# Fail closed: anything other than the clean sentinel (incl. a python
|
||||||
|
# failure that yields an empty string) is treated as a deny.
|
||||||
|
if [[ "$subst_git" != "__OK__no" ]]; then
|
||||||
|
echo "Denied: a git verb inside a command substitution (\$(...) or backticks) is evaluated by the shell before the wrapping echo/printf/heredoc runs." >&2
|
||||||
|
echo "Use the verb listed in your role's State→Verb table (e.g. commit, complete, i_am_done)." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- credential / secret exfil ------------------------------------------------
|
# --- credential / secret exfil ------------------------------------------------
|
||||||
# Block ANY bash command that references a credential file path — catches
|
# Block ANY bash command that references a credential file path — catches
|
||||||
# `cat .git/config`, `python -c "open('.git/config')..."`, `grep token .git/
|
# `cat .git/config`, `python -c "open('.git/config')..."`, `grep token .git/
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ code 2 to deny, 0 to allow. Tests wrap each command in that envelope.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -204,6 +205,46 @@ def test_allows_unquoted_heredoc_documenting_git() -> None:
|
|||||||
assert _run(cmd) == _ALLOWED
|
assert _run(cmd) == _ALLOWED
|
||||||
|
|
||||||
|
|
||||||
|
def _run_skip_git(cmd: str) -> int:
|
||||||
|
"""Run the hook the way grok does — ROBOCO_GUARD_SKIP_GIT=1."""
|
||||||
|
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}})
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(GUARD)],
|
||||||
|
input=payload,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
env={**os.environ, "ROBOCO_GUARD_SKIP_GIT": "1"},
|
||||||
|
)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
# Command-substitution bypass: a git verb inside $(...) / `...` is expanded by
|
||||||
|
# the shell before the wrapping echo/printf runs, so the skeletonizer's strip
|
||||||
|
# would otherwise hide it from the git check.
|
||||||
|
def test_denies_git_verb_in_dollar_substitution() -> None:
|
||||||
|
assert _run("echo $(git fetch origin)") == _DENIED
|
||||||
|
|
||||||
|
|
||||||
|
def test_denies_git_verb_in_double_quoted_substitution() -> None:
|
||||||
|
assert _run('printf "%s" "$(git push)"') == _DENIED
|
||||||
|
|
||||||
|
|
||||||
|
def test_denies_git_verb_in_backtick_substitution() -> None:
|
||||||
|
assert _run("echo `git push origin main`") == _DENIED
|
||||||
|
|
||||||
|
|
||||||
|
def test_allows_single_quoted_literal_substitution() -> None:
|
||||||
|
# Single-quoted: the shell does NOT expand it — a literal, not a run.
|
||||||
|
assert _run("echo '$(git push)'") == _ALLOWED
|
||||||
|
|
||||||
|
|
||||||
|
def test_substitution_check_skipped_on_grok() -> None:
|
||||||
|
# On grok (SKIP_GIT=1) git is the native --deny's job; the hook must NOT
|
||||||
|
# hard-cancel the run on a substitution.
|
||||||
|
assert _run_skip_git("echo $(git push)") == _ALLOWED
|
||||||
|
|
||||||
|
|
||||||
def test_allows_dash_heredoc_documenting_git() -> None:
|
def test_allows_dash_heredoc_documenting_git() -> None:
|
||||||
"""`<<-DELIM` indents the closing delimiter; body still stripped."""
|
"""`<<-DELIM` indents the closing delimiter; body still stripped."""
|
||||||
cmd = "cat > n.md <<-EOF\n\tgit rebase main then git push --force\n\tEOF"
|
cmd = "cat > n.md <<-EOF\n\tgit rebase main then git push --force\n\tEOF"
|
||||||
|
|||||||
Reference in New Issue
Block a user