Skip to content

Commit 5bbac12

Browse files
authored
fix(danger): close H1-H3 classifier gaps (git config, find/rsync deletes, shell rc targets)
1 parent b46816b commit 5bbac12

4 files changed

Lines changed: 288 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
181181
- **Web UI prompt/model validation** (`cmd/odek/serve.go`) — server-side cap on WebSocket prompt size (1 MiB) and validation of model ID length/characters, preventing oversized or unusual payloads from reaching the LLM.
182182
- **Sub-agent runaway limits** (`cmd/odek/subagent.go`) — `--timeout` is capped at 3600s and `--max-iter` at 100, so a single `odek subagent` invocation cannot run indefinitely.
183183
- **Secrets.env permission gate** (`internal/config/loader.go`) — refuses to load `~/.odek/secrets.env` when it is group/world-readable, preventing local users from reading API keys injected into the environment.
184+
- **git config code execution** (`internal/danger/classifier.go`) — `git -c alias.*=!<cmd>`, `git -c core.pager=...`, `git -c core.fsmonitor=...`, `git -c credential.helper=...`, and the `git config` subcommand are classified as `code_execution` because they can inject arbitrary shell commands.
185+
- **find / rsync destructive flags** (`internal/danger/classifier.go`) — `find -delete` and `rsync --delete` / `--del` / `--remove-source-files` are classified as `destructive`; `find -fprint` / `-fprintf` are classified as `local_write`.
186+
- **Shell operand path classification** (`internal/danger/classifier.go`) — file operands and redirect targets of shell commands are checked with `ClassifyPath`, so writes to `~/.bashrc`, `~/.zshrc`, `~/.profile`, `~/.ssh`, `~/.odek`, etc. are escalated to `system_write` instead of auto-allowed `local_write`.
184187
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
185188

186189
### Security findings (`sec_findings.md`)

docs/SECURITY.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ The classifier is hardened against common evasion tricks (see the package doc in
9595
- `wipefs`, `blkdiscard`, `sgdisk`/`gdisk`/`cfdisk`/`sfdisk`, `mkswap`, `badblocks`, `cryptsetup`, and the `mkfs.*` family are `destructive`; `shred` is target-aware (local file → `local_write`, raw device / wipe target → `destructive`).
9696
- `shutdown`, `reboot`, `halt`, `poweroff`, `init 0`/`init 6` — machine power-control commands are `destructive` (deny-by-default) with an accurate label instead of falling through to `unknown`.
9797
- `env` and `printenv` — a full process-environment dump is classified as `system_write` because it can leak secrets that the redaction scanner does not recognise. `env FOO=bar <cmd>` still classifies the real `<cmd>` normally.
98+
- `git -c alias.x='!id' x`, `git -c core.pager='sh -c id' --paginate log`, `git config --global alias.pwn '!cmd'``git -c` / `--config-env` overrides and the `git config` subcommand are `code_execution` because they can define arbitrary shell commands.
99+
- `find . -delete`, `rsync -a --delete /empty/ ~`, `rsync --remove-source-files` — bulk-deletion flags are `destructive`; `find -fprint` / `-fprintf` are `local_write` because they write match lists to arbitrary files.
100+
- `echo x >> ~/.bashrc`, `cp evil ~/.profile`, `dd if=evil of=~/.bashrc` — shell file operands and redirect targets are run through `ClassifyPath`, so writes to shell rc files, `~/.ssh`, `~/.odek` trust anchors, and other home-sensitive paths are `system_write` instead of auto-allowed `local_write`.
98101

99102
Regression suites (`internal/danger/classifier_bypass_test.go` and `hardening_test.go`) pin these as known-closed evasions. If you find a new bypass, those test files are the place to add it.
100103

internal/danger/classifier.go

Lines changed: 191 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,86 @@ func isSensitiveOdekPath(tok string) bool {
15721572
return isOdekTrustAnchor(home, abs)
15731573
}
15741574

1575+
// classifyShellTokenPath expands ~ and common environment-variable shorthands
1576+
// in a shell token, strips prefixes like "of=", and returns the ClassifyPath
1577+
// result for the filesystem path it names. It lets shell commands be checked
1578+
// against the same home-sensitive-dir and rc-file lists used by the file tools,
1579+
// closing the gap where `echo x >> ~/.bashrc` was auto-allowed as local_write.
1580+
func classifyShellTokenPath(tok string) RiskClass {
1581+
path := tok
1582+
1583+
// Strip common key=value prefixes used by dd and similar tools.
1584+
for _, prefix := range []string{"of=", "if="} {
1585+
if strings.HasPrefix(strings.ToLower(path), prefix) {
1586+
path = path[len(prefix):]
1587+
break
1588+
}
1589+
}
1590+
if path == "" {
1591+
return Safe
1592+
}
1593+
1594+
// Expand ~ and simple $HOME/${HOME} forms that appear in shell commands.
1595+
home, _ := os.UserHomeDir()
1596+
if home != "" {
1597+
if strings.HasPrefix(path, "~") {
1598+
path = home + path[1:]
1599+
} else if path == "$HOME" || strings.HasPrefix(path, "$HOME/") {
1600+
path = home + path[len("$HOME"):]
1601+
} else if path == "${HOME}" || strings.HasPrefix(path, "${HOME}/") {
1602+
path = home + path[len("${HOME}"):]
1603+
}
1604+
}
1605+
1606+
return ClassifyPath(path)
1607+
}
1608+
1609+
// shellPathIsSensitive reports whether a shell token names a path that should
1610+
// be treated as system_write or worse. It is used for both write operands and
1611+
// general path arguments so reads of rc files and trust anchors are gated.
1612+
func shellPathIsSensitive(tok string) bool {
1613+
cls := classifyShellTokenPath(tok)
1614+
return Rank(cls) >= Rank(SystemWrite)
1615+
}
1616+
1617+
// shellPathIsHomeSensitive reports whether a shell token names a path under
1618+
// the user's home directory that ClassifyPath considers system_write or worse
1619+
// (e.g. ~/.bashrc, ~/.ssh/id_rsa, ~/.odek/config.json). It is narrower than
1620+
// shellPathIsSensitive so that touching /etc itself does not break the existing
1621+
// classification of commands like `cd /etc`.
1622+
func shellPathIsHomeSensitive(tok string) bool {
1623+
home, err := os.UserHomeDir()
1624+
if err != nil || home == "" {
1625+
return false
1626+
}
1627+
path := tok
1628+
for _, prefix := range []string{"of=", "if="} {
1629+
if strings.HasPrefix(strings.ToLower(path), prefix) {
1630+
path = path[len(prefix):]
1631+
break
1632+
}
1633+
}
1634+
if path == "" {
1635+
return false
1636+
}
1637+
if strings.HasPrefix(path, "~") {
1638+
path = home + path[1:]
1639+
} else if path == "$HOME" || strings.HasPrefix(path, "$HOME/") {
1640+
path = home + path[len("$HOME"):]
1641+
} else if path == "${HOME}" || strings.HasPrefix(path, "${HOME}/") {
1642+
path = home + path[len("${HOME}"):]
1643+
}
1644+
abs, err := filepath.Abs(path)
1645+
if err != nil {
1646+
return false
1647+
}
1648+
abs = filepath.Clean(abs)
1649+
if abs != home && !strings.HasPrefix(abs, home+"/") {
1650+
return false
1651+
}
1652+
return Rank(ClassifyPath(abs)) >= Rank(SystemWrite)
1653+
}
1654+
15751655
// ── Small token helpers ────────────────────────────────────────────────
15761656

15771657
// commandName returns the program name from a token, taking the basename of
@@ -1629,6 +1709,21 @@ func hasAny(tokens []string, names ...string) bool {
16291709
return false
16301710
}
16311711

1712+
// rsyncDeleteFlags are flags that cause rsync to delete files on the
1713+
// destination or remove them from the source — unrecoverable bulk deletion.
1714+
func hasAnyRsyncDelete(tokens []string) bool {
1715+
for _, t := range tokens {
1716+
if strings.HasPrefix(t, "--delete") {
1717+
return true
1718+
}
1719+
switch t {
1720+
case "--del", "--remove-source-files":
1721+
return true
1722+
}
1723+
}
1724+
return false
1725+
}
1726+
16321727
// isAssignment reports whether a token is a NAME=VALUE shell assignment
16331728
// (used to skip `env FOO=bar … cmd`). A leading-slash token like
16341729
// /a=b is a path, not an assignment.
@@ -1854,6 +1949,16 @@ func isDestructive(first string, tokens []string) bool {
18541949
return false
18551950
}
18561951

1952+
// find -delete removes every matched file; rsync --delete / --remove-source-files
1953+
// can wipe a directory tree. Both are unrecoverable bulk deletion, equivalent to
1954+
// rm -rf, so they classify as destructive.
1955+
if first == "find" && hasAny(tokens, "-delete") {
1956+
return true
1957+
}
1958+
if first == "rsync" && hasAnyRsyncDelete(tokens) {
1959+
return true
1960+
}
1961+
18571962
if !destructivePrefixes[first] || len(tokens) < 2 {
18581963
return false
18591964
}
@@ -1905,17 +2010,17 @@ func isSystemWrite(first string, tokens []string) bool {
19052010
// write check. Escalate them here so they prompt instead.
19062011
if writePrefixes[first] || first == "ln" || first == "install" {
19072012
for _, tok := range tokens[1:] {
1908-
if isSystemPath(tok) {
2013+
if shellPathIsSensitive(tok) {
19092014
return true
19102015
}
19112016
}
19122017
}
1913-
// Check redirect targets for system paths
2018+
// Check redirect targets for sensitive paths
19142019
for _, tok := range tokens {
19152020
if tok == ">" || tok == ">>" {
19162021
continue
19172022
}
1918-
if isSystemPath(tok) {
2023+
if shellPathIsSensitive(tok) {
19192024
// Check if it's a redirect target (token follows > or >>)
19202025
for i, t := range tokens {
19212026
if (t == ">" || t == ">>") && i+1 < len(tokens) && tokens[i+1] == tok {
@@ -1924,6 +2029,15 @@ func isSystemWrite(first string, tokens []string) bool {
19242029
}
19252030
}
19262031
}
2032+
// dd of=... writes its output to an arbitrary path; catch writes to
2033+
// rc files and trust anchors that would otherwise slip through as safe.
2034+
if first == "dd" {
2035+
for _, tok := range tokens {
2036+
if strings.HasPrefix(strings.ToLower(tok), "of=") && shellPathIsSensitive(tok) {
2037+
return true
2038+
}
2039+
}
2040+
}
19272041
return false
19282042
}
19292043

@@ -1994,6 +2108,10 @@ func isLocalWrite(first string, tokens []string) bool {
19942108
if writePrefixes[first] {
19952109
return true
19962110
}
2111+
// find -fprint/-fprintf write match lists to a file (arbitrary path)
2112+
if first == "find" && hasAny(tokens, "-fprint", "-fprintf") {
2113+
return true
2114+
}
19972115
// Any command with > or >> is a write
19982116
for _, tok := range tokens {
19992117
if tok == ">" || tok == ">>" {
@@ -2067,7 +2185,72 @@ func isNetworkEgress(first string, tokens []string) bool {
20672185
return true
20682186
}
20692187

2188+
// gitCodeExecConfigKeys are git config keys whose values cause arbitrary
2189+
// command execution when set via -c/--config-env or git config.
2190+
var gitCodeExecConfigKeys = map[string]bool{
2191+
"core.pager": true,
2192+
"core.fsmonitor": true,
2193+
"credential.helper": true,
2194+
}
2195+
2196+
// isGitCodeExecution reports whether a git invocation carries a config override
2197+
// or subcommand that can execute arbitrary shell code. It deliberately errs on
2198+
// the side of classification rather than parsing the full git config grammar:
2199+
// aliases whose value starts with "!" run shell commands, and a small set of
2200+
// core/credential keys spawn pagers or helpers.
2201+
func isGitCodeExecution(tokens []string) bool {
2202+
for i := 0; i < len(tokens); i++ {
2203+
tok := tokens[i]
2204+
if tok == "git" {
2205+
continue
2206+
}
2207+
2208+
var val string
2209+
consumed := false
2210+
switch {
2211+
case tok == "-c" || tok == "--config-env":
2212+
if i+1 < len(tokens) {
2213+
val = tokens[i+1]
2214+
consumed = true
2215+
}
2216+
case strings.HasPrefix(tok, "-c"):
2217+
val = tok[2:]
2218+
case strings.HasPrefix(tok, "--config-env="):
2219+
val = tok[len("--config-env="):]
2220+
case !strings.HasPrefix(tok, "-"):
2221+
// Once we hit the subcommand, global options are done.
2222+
if tok == "config" {
2223+
return true
2224+
}
2225+
}
2226+
2227+
if consumed {
2228+
i++
2229+
}
2230+
if val == "" {
2231+
continue
2232+
}
2233+
2234+
key, value, _ := strings.Cut(val, "=")
2235+
key = strings.ToLower(key)
2236+
if strings.HasPrefix(key, "alias.") && strings.HasPrefix(value, "!") {
2237+
return true
2238+
}
2239+
if gitCodeExecConfigKeys[key] {
2240+
return true
2241+
}
2242+
}
2243+
return false
2244+
}
2245+
20702246
func isCodeExecution(first string, tokens []string) bool {
2247+
// git -c/--config-env can inject arbitrary shell commands via aliases,
2248+
// core.pager, core.fsmonitor, credential.helper, etc.; git config writes
2249+
// can persist the same payloads.
2250+
if first == "git" && isGitCodeExecution(tokens) {
2251+
return true
2252+
}
2253+
20712254
// Pipe to shell interpreter
20722255
for i, tok := range tokens {
20732256
if tok == "|" && i+1 < len(tokens) && pipedShells[commandName(tokens[i+1])] {
@@ -2317,16 +2500,18 @@ func hasArgAfter(tokens []string, after, target string) bool {
23172500
return false
23182501
}
23192502

2320-
// touchesSystemPath reports whether any token names a system path (an
2503+
// touchesSystemPath reports whether any token names a sensitive path (an
23212504
// argument or a redirect target alike). It is intentionally broader than the
23222505
// redirect-only scan in isSystemWrite — it catches reads/args such as
2323-
// `cat /etc/foo` or an unknown tool pointed at /usr — so both checks exist.
2506+
// `cat /etc/foo`, `cat ~/.bashrc`, or an unknown tool pointed at /usr — so both
2507+
// checks exist. It keeps the legacy trailing-slash system-prefix check and
2508+
// additionally uses ClassifyPath for home rc files and trust anchors.
23242509
func touchesSystemPath(tokens []string) bool {
23252510
for _, tok := range tokens {
23262511
if tok == ">" || tok == ">>" {
23272512
continue
23282513
}
2329-
if isSystemPath(tok) {
2514+
if isSystemPath(tok) || shellPathIsHomeSensitive(tok) {
23302515
return true
23312516
}
23322517
}

internal/danger/classifier_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,97 @@ func TestClassify_RsyncRemote(t *testing.T) {
715715
}
716716
}
717717

718+
// TestClassify_GitConfigCodeExecution verifies H-1: git -c/--config-env and
719+
// git config can inject arbitrary shell commands through aliases, pager, and
720+
// credential helpers, so they must classify as code_execution.
721+
func TestClassify_GitConfigCodeExecution(t *testing.T) {
722+
tests := []struct {
723+
cmd string
724+
want RiskClass
725+
}{
726+
{`git -c alias.x='!id' x`, CodeExecution},
727+
{`git -c alias.x=!id x`, CodeExecution},
728+
{`git -calias.x=!id x`, CodeExecution},
729+
{`git -c core.pager='sh -c id' --paginate log`, CodeExecution},
730+
{`git -c core.fsmonitor='!pwn' status`, CodeExecution},
731+
{`git -c credential.helper='!curl evil' clone`, CodeExecution},
732+
{`git config --global alias.pwn '!cmd'`, CodeExecution},
733+
{`git config user.email x`, CodeExecution},
734+
// Benign config overrides stay in their normal class.
735+
{`git -c http.proxy=http://evil fetch origin`, NetworkEgress},
736+
{`git -C /repo status`, Safe},
737+
{`git status`, Safe},
738+
}
739+
for _, tt := range tests {
740+
t.Run(tt.cmd, func(t *testing.T) {
741+
got := Classify(tt.cmd)
742+
if got != tt.want {
743+
t.Errorf("Classify(%q) = %s, want %s", tt.cmd, got, tt.want)
744+
}
745+
})
746+
}
747+
}
748+
749+
// TestClassify_FindRsyncDestructive verifies H-2: find -delete and rsync
750+
// --delete / --remove-source-files can wipe directory trees, so they are
751+
// destructive; find -fprint/-fprintf are local writes.
752+
func TestClassify_FindRsyncDestructive(t *testing.T) {
753+
tests := []struct {
754+
cmd string
755+
want RiskClass
756+
}{
757+
{"find . -delete", Destructive},
758+
{"find ~ -delete", Destructive},
759+
{"find . -fprint ~/.bashrc", LocalWrite},
760+
{"find . -fprintf /tmp/list '%p\\n'", LocalWrite},
761+
{"rsync -a --delete /tmp/empty/ ~", Destructive},
762+
{"rsync -av --remove-source-files /a /b", Destructive},
763+
{"rsync -av --del /a /b", Destructive},
764+
{"rsync -av /src/ /dst/", Safe},
765+
{"rsync -av /src/ user@host:/dst/", NetworkEgress},
766+
}
767+
for _, tt := range tests {
768+
t.Run(tt.cmd, func(t *testing.T) {
769+
got := Classify(tt.cmd)
770+
if got != tt.want {
771+
t.Errorf("Classify(%q) = %s, want %s", tt.cmd, got, tt.want)
772+
}
773+
})
774+
}
775+
}
776+
777+
// TestClassify_ShellRCTargets verifies H-3: shell writes to shell rc files
778+
// and other home-sensitive paths are escalated to system_write using the same
779+
// ClassifyPath lists that gate the file tools.
780+
func TestClassify_ShellRCTargets(t *testing.T) {
781+
tests := []struct {
782+
cmd string
783+
want RiskClass
784+
}{
785+
{"echo x >> ~/.bashrc", SystemWrite},
786+
{"echo x >> ~/.zshrc", SystemWrite},
787+
{"echo x >> ~/.profile", SystemWrite},
788+
{"echo x >> $HOME/.bashrc", SystemWrite},
789+
{"cp evil ~/.profile", SystemWrite},
790+
{"tee -a ~/.zshrc", SystemWrite},
791+
{"dd if=evil of=~/.bashrc", SystemWrite},
792+
{"cat ~/.bashrc", SystemWrite},
793+
{"cat ~/.ssh/id_rsa", SystemWrite},
794+
{"cat ~/.odek/config.json", SystemWrite},
795+
// Non-rc home paths stay local_write.
796+
{"mv evil ~/.local/bin/git", LocalWrite},
797+
{"echo x > ~/notes.txt", LocalWrite},
798+
}
799+
for _, tt := range tests {
800+
t.Run(tt.cmd, func(t *testing.T) {
801+
got := Classify(tt.cmd)
802+
if got != tt.want {
803+
t.Errorf("Classify(%q) = %s, want %s", tt.cmd, got, tt.want)
804+
}
805+
})
806+
}
807+
}
808+
718809
func TestClassify_PythonDashC(t *testing.T) {
719810
got := Classify("python -c 'print(1)'")
720811
if got != CodeExecution {

0 commit comments

Comments
 (0)