@@ -6,7 +6,7 @@ import (
66 "github.com/stretchr/testify/assert"
77)
88
9−func TestContainsCommandChaining(t *testing.T) {
9+func TestIsSafeReadOnly(t *testing.T) {
1010 t.Parallel()
1111
1212 tests := []struct {
@@ -14,34 +14,61 @@ func TestContainsCommandChaining(t *testing.T) {
1414 input string
1515 expected bool
1616 }{
17− {"plain ls", "ls -la", false},
18− {"plain echo", "echo hello world", false},
19− {"plain pwd", "pwd", false},
20− {"plain git status", "git status", false},
21− {"ls with redirect", "ls > /tmp/out", false},
22− {"ls with pipe", "ls | grep foo", true},
23− {"ls with double ampersand", "ls && echo done", true},
24− {"ls with semicolon", "ls; echo done", true},
25− {"ls with pipe pipe", "ls || echo fail", true},
26− {"ls with backticks", "ls `echo foo`", true},
27− {"ls with subshell", "ls $(echo foo)", true},
28− {"ls with background ampersand", "ls & echo done", false},
29− {"rm -rf with && ls (rm first)", "rm -rf / && ls", true},
30− {"redirect with ampersand gt", "ls &> /dev/null", false},
31− {"redirect with gt ampersand", "ls >& /dev/null", false},
32− {"simple kill", "kill 1234", false},
33− {"kill with pipe", "kill 1234 | echo foo", true},
34− {"git log", "git log --oneline", false},
35− {"git log with pipe", "git log | head", true},
36− {"empty string", "", false},
37− {"dollar sign in argument", "echo $HOME", false},
17+ // Plain safe commands.
18+ {"plain ls", "ls -la", true},
19+ {"plain echo", "echo hello world", true},
20+ {"plain pwd", "pwd", true},
21+ {"git status", "git status", true},
22+ {"git log with flags", "git log --oneline", true},
23+ {"git config --get", "git config --get user.name", true},
24+ {"echo with param expansion", "echo $HOME", true},
25+ {"simple kill", "kill 1234", true},
26+
27+ // Not on the allowlist.
28+ {"rm", "rm -rf /tmp/x", false},
29+ {"unknown", "frobnicate", false},
30+ {"git push not allowed", "git push", false},
31+ {"prefix is not a match (lsof)", "lsof", false},
32+
33+ // Chaining / multiple statements.
34+ {"pipe", "ls | grep foo", false},
35+ {"and", "ls && echo done", false},
36+ {"or", "ls || echo fail", false},
37+ {"semicolon", "ls; echo done", false},
38+ {"newline", "echo hi\nrm -rf x", false},
39+ {"background then rm", "echo hi & rm -rf x", false},
40+ {"rm first with and", "rm -rf / && ls", false},
41+
42+ // Substitution.
43+ {"command subst", "ls $(rm -rf x)", false},
44+ {"backticks", "ls `rm -rf x`", false},
45+ {"param default with subst", "echo ${x:-$(rm -rf x)}", false},
46+
47+ // Redirection.
48+ {"redirect out", "echo x > /tmp/out", false},
49+ {"redirect append", "echo x >> ~/.bashrc", false},
50+ {"redirect in", "cat < /etc/passwd", false},
51+
52+ // Wrapper commands must not smuggle an unapproved command through.
53+ {"time wrapper", "time rm -rf x", false},
54+ {"env wrapper", "env FOO=bar rm -rf x", false},
55+ {"timeout wrapper", "timeout 10 rm -rf x", false},
56+ {"nohup wrapper", "nohup rm -rf x", false},
57+ {"nice wrapper", "nice rm -rf x", false},
58+
59+ // Inline assignment (e.g. LD_PRELOAD) is not safe.
60+ {"inline assignment", "LD_PRELOAD=/evil.so ls", false},
61+
62+ // Malformed / empty.
63+ {"empty", "", false},
64+ {"unbalanced quote", "echo \"unterminated", false},
3865 }
3966
4067 for _, tt := range tests {
4168 t.Run(tt.name, func(t *testing.T) {
4269 t.Parallel()
43− got := containsCommandChaining(tt.input)
44− assert.Equal(t, tt.expected, got, "containsCommandChaining(%q)", tt.input)
70+ got := isSafeReadOnly(tt.input)
71+ assert.Equal(t, tt.expected, got, "isSafeReadOnly(%q)", tt.input)
4572 })
4673 }
4774}