315 lines
8.6 KiB
Go
315 lines
8.6 KiB
Go
package permission
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestDecomposeBashCommand(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "atomic command returns nil",
|
|
in: "git status",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "atomic with redirect (has shell syntax but no operator) returns nil",
|
|
in: "grep -r TODO . 2>/dev/null",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "&& chain",
|
|
in: `git add . && git commit -m "wip" && git push`,
|
|
want: []string{"git add .", `git commit -m "wip"`, "git push"},
|
|
},
|
|
{
|
|
name: "|| fallback",
|
|
in: `sudo chmod 644 /etc/foo || echo "chmod failed"`,
|
|
want: []string{"sudo chmod 644 /etc/foo", `echo "chmod failed"`},
|
|
},
|
|
{
|
|
name: "pipe",
|
|
in: "git log --oneline | head -20",
|
|
want: []string{"git log --oneline", "head -20"},
|
|
},
|
|
{
|
|
name: "semicolon",
|
|
in: "cd /tmp; ls -la",
|
|
want: []string{"cd /tmp", "ls -la"},
|
|
},
|
|
{
|
|
name: "mixed compound",
|
|
in: `sudo chmod 644 /etc/ssh/foo 2>/dev/null || echo "sudo not available, trying alternative" && ssh -T git@github.com 2>&1`,
|
|
want: []string{
|
|
"sudo chmod 644 /etc/ssh/foo 2>/dev/null",
|
|
`echo "sudo not available, trying alternative"`,
|
|
"ssh -T git@github.com 2>&1",
|
|
},
|
|
},
|
|
{
|
|
name: "operator inside single quotes stays intact",
|
|
in: `echo 'a && b' && ls`,
|
|
want: []string{`echo 'a && b'`, "ls"},
|
|
},
|
|
{
|
|
name: "operator inside double quotes stays intact",
|
|
in: `echo "x | y" | wc -l`,
|
|
want: []string{`echo "x | y"`, "wc -l"},
|
|
},
|
|
{
|
|
name: "operator inside $(...) stays intact",
|
|
in: `echo $(git rev-parse HEAD; date) && ls`,
|
|
want: []string{`echo $(git rev-parse HEAD; date)`, "ls"},
|
|
},
|
|
{
|
|
name: "operator inside backticks stays intact",
|
|
in: "echo `git status; ls` && date",
|
|
want: []string{"echo `git status; ls`", "date"},
|
|
},
|
|
{
|
|
name: "2>&1 redirection is not a splitter",
|
|
in: "go test ./... 2>&1 | tee log",
|
|
want: []string{"go test ./... 2>&1", "tee log"},
|
|
},
|
|
{
|
|
name: "&>/dev/null redirection is not a splitter",
|
|
in: "git log &>/dev/null | head -20",
|
|
want: []string{"git log &>/dev/null", "head -20"},
|
|
},
|
|
{
|
|
name: "leading &>/dev/null redirection is not malformed",
|
|
in: "&>/dev/null git log | head -20",
|
|
want: []string{"&>/dev/null git log", "head -20"},
|
|
},
|
|
{
|
|
name: "empty tail after trailing operator is dropped",
|
|
in: "ls -la;",
|
|
want: nil, // only one non-empty segment after split
|
|
},
|
|
{
|
|
name: "unclosed quote returns nil (falls back to exact)",
|
|
in: `echo "hello && ls`,
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "unclosed $(...) returns nil",
|
|
in: "echo $(git status && ls",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "newline splits",
|
|
in: "cd /tmp\nls",
|
|
want: []string{"cd /tmp", "ls"},
|
|
},
|
|
{
|
|
name: "heredoc bails to nil (known out-of-scope)",
|
|
in: "cat <<EOF && ls\nline1\nEOF",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "leading && is malformed, returns nil",
|
|
in: "&& ls",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "leading || is malformed, returns nil",
|
|
in: "|| echo hi",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "leading ; is malformed, returns nil",
|
|
in: "; ls",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "leading | is malformed, returns nil",
|
|
in: "| grep foo",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "process substitution <(cmd) is opaque, operators inside don't split",
|
|
in: "diff <(git log -1 | head) <(git show HEAD | head) && ls",
|
|
want: []string{
|
|
"diff <(git log -1 | head) <(git show HEAD | head)",
|
|
"ls",
|
|
},
|
|
},
|
|
{
|
|
name: "process substitution >(cmd) is opaque",
|
|
in: "tee >(gzip | tar) && date",
|
|
want: []string{"tee >(gzip | tar)", "date"},
|
|
},
|
|
{
|
|
name: "single < is redirect, stays with segment",
|
|
in: "sort < input.txt && ls",
|
|
want: []string{"sort < input.txt", "ls"},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := DecomposeBashCommand(tt.in)
|
|
if !reflect.DeepEqual(got, tt.want) {
|
|
t.Errorf("DecomposeBashCommand(%q)\n got: %#v\n want: %#v", tt.in, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPolicyDecideCompoundBash(t *testing.T) {
|
|
// Simulate a user who has approved `git add`, `git commit`, `git push`
|
|
// atomically at some earlier point — either via config or via the
|
|
// prefix-rule save path that already exists.
|
|
p := New("ask", []string{
|
|
"Bash(git add:*)",
|
|
"Bash(git commit:*)",
|
|
"Bash(git push:*)",
|
|
"Bash(go test:*)",
|
|
"Bash(sudo chmod:*)",
|
|
}, nil, []string{
|
|
"Bash(rm -rf*)",
|
|
})
|
|
|
|
cases := []struct {
|
|
name string
|
|
subject string
|
|
want Decision
|
|
}{
|
|
{
|
|
name: "compound of atomic-allowed segments passes",
|
|
subject: `git add . && git commit -m "wip" && git push`,
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "one uncovered segment turns into Ask",
|
|
subject: `git add . && git commit -m "wip" && git push && npm publish`,
|
|
want: Ask,
|
|
},
|
|
{
|
|
name: "deny in any segment wins",
|
|
subject: `git add . && rm -rf /tmp/scratch`,
|
|
want: Deny,
|
|
},
|
|
{
|
|
name: "read-only segments auto-allow without a rule",
|
|
subject: `echo starting && git add . && ls -la`,
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "compound with || also passes when segments have no redirects",
|
|
subject: `sudo chmod 644 /etc/foo || echo "chmod failed"`,
|
|
// sudo chmod ... → matches Bash(sudo chmod:*)
|
|
// echo "..." → readonly builtin
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "segment with dev null redirect still matches prefix rule",
|
|
subject: `sudo chmod 644 /etc/foo 2>/dev/null || echo "chmod failed"`,
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "segment with file redirect still misses prefix rule",
|
|
subject: `sudo chmod 644 /etc/foo > chmod.log || echo "chmod failed"`,
|
|
want: Ask,
|
|
},
|
|
{
|
|
name: "segment with fd duplication still matches prefix rule",
|
|
subject: `go test ./... 2>&1 | head -20`,
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "read-only segment with dev null redirect auto-allows",
|
|
subject: `git log --oneline 2>/dev/null | head -20`,
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "write-capable read-only-looking arg still asks after safe redirect",
|
|
subject: `git diff --output changes.patch 2>/dev/null | head -20`,
|
|
want: Ask,
|
|
},
|
|
{
|
|
name: "atomic subject with matching prefix rule still allows",
|
|
subject: "git push origin main",
|
|
want: Allow,
|
|
},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := p.DecideSubject("bash", false, tt.subject)
|
|
if got != tt.want {
|
|
t.Errorf("DecideSubject(%q) = %v, want %v", tt.subject, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPolicyDecideCompoundBashUsesWriterFallback(t *testing.T) {
|
|
command := `$file = Get-ChildItem -Path "D:\fixtures\reports" -Filter "*sample*.txt" | Select-Object -First 1; python -c "import sys; f=open(sys.argv[1], 'r', encoding='utf-8'); print(f.read()[:10000]); f.close()" $file.FullName`
|
|
|
|
cases := []struct {
|
|
name string
|
|
mode string
|
|
want Decision
|
|
}{
|
|
{
|
|
name: "auto writer fallback allows uncovered compound bash segments",
|
|
mode: "allow",
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "ask writer fallback still prompts for uncovered compound bash segments",
|
|
mode: "ask",
|
|
want: Ask,
|
|
},
|
|
{
|
|
name: "deny writer fallback still blocks uncovered compound bash segments",
|
|
mode: "deny",
|
|
want: Deny,
|
|
},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := New(tt.mode, nil, nil, nil)
|
|
if got := p.DecideSubject("bash", false, command); got != tt.want {
|
|
t.Fatalf("DecideSubject(mode=%q) = %v, want %v", tt.mode, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPolicyDecideCompoundBashPreservesWholeCommandRules(t *testing.T) {
|
|
subject := `git add . && git commit -m "wip" && git push`
|
|
|
|
t.Run("exact allow still wins before segment decomposition", func(t *testing.T) {
|
|
p := New("ask", []string{`Bash(git add . && git commit -m "wip" && git push)`}, nil, nil)
|
|
if got := p.DecideSubject("bash", false, subject); got != Allow {
|
|
t.Fatalf("DecideSubject(%q) = %v, want %v", subject, got, Allow)
|
|
}
|
|
})
|
|
|
|
t.Run("exact deny still beats segment allows", func(t *testing.T) {
|
|
p := New("ask", []string{
|
|
"Bash(git add:*)",
|
|
"Bash(git commit:*)",
|
|
"Bash(git push:*)",
|
|
}, nil, []string{`Bash(git add . && git commit -m "wip" && git push)`})
|
|
if got := p.DecideSubject("bash", false, subject); got != Deny {
|
|
t.Fatalf("DecideSubject(%q) = %v, want %v", subject, got, Deny)
|
|
}
|
|
})
|
|
|
|
t.Run("exact ask still beats segment allows", func(t *testing.T) {
|
|
p := New("allow", []string{
|
|
"Bash(git add:*)",
|
|
"Bash(git commit:*)",
|
|
"Bash(git push:*)",
|
|
}, []string{`Bash(git add . && git commit -m "wip" && git push)`}, nil)
|
|
if got := p.DecideSubject("bash", false, subject); got != Ask {
|
|
t.Fatalf("DecideSubject(%q) = %v, want %v", subject, got, Ask)
|
|
}
|
|
})
|
|
}
|