| 1 | package permission |
| 2 | |
| 3 | import "testing" |
| 4 | |
| 5 | func TestBashSubjectRequiresExplicitApproval(t *testing.T) { |
| 6 | tests := []struct { |
| 7 | name string |
| 8 | subject string |
| 9 | wantHuman bool |
| 10 | wantExact bool |
| 11 | }{ |
| 12 | {name: "plain static command", subject: "git status --short"}, |
| 13 | {name: "static compound command", subject: "git status && npm test"}, |
| 14 | {name: "safe null redirect", subject: "git status 2>/dev/null"}, |
| 15 | {name: "simple sudo command", subject: "sudo chmod 644 file"}, |
| 16 | {name: "non-indirect builtin", subject: "builtin printf '%s\\n' ok"}, |
| 17 | {name: "command substitution", subject: "git status $(touch /tmp/x)", wantHuman: true, wantExact: true}, |
| 18 | {name: "backtick substitution", subject: "git status `touch /tmp/x`", wantHuman: true, wantExact: true}, |
| 19 | {name: "process substitution input", subject: "diff <(touch /tmp/x) expected", wantHuman: true, wantExact: true}, |
| 20 | {name: "process substitution output", subject: "tee >(touch /tmp/x)", wantHuman: true, wantExact: true}, |
| 21 | {name: "parameter expansion", subject: "git diff $REV", wantExact: true}, |
| 22 | {name: "arithmetic expansion", subject: "echo $((1 + 1))", wantExact: true}, |
| 23 | {name: "brace expansion", subject: "printf '%s\\n' {a,b}", wantExact: true}, |
| 24 | {name: "extended glob", subject: "printf '%s\\n' @(a|b)", wantExact: true}, |
| 25 | {name: "environment assignment", subject: "REV=HEAD git diff", wantExact: true}, |
| 26 | {name: "env wrapper assignment", subject: "env REV=HEAD git diff", wantExact: true}, |
| 27 | {name: "file redirect", subject: "git status > status.txt", wantExact: true}, |
| 28 | {name: "unquoted glob", subject: "rm *.log", wantExact: true}, |
| 29 | {name: "heredoc", subject: "cat <<EOF\nhello\nEOF", wantExact: true}, |
| 30 | {name: "heredoc nested execution", subject: "cat <<EOF\n$(touch /tmp/x)\nEOF", wantHuman: true, wantExact: true}, |
| 31 | {name: "eval", subject: `eval "touch /tmp/x"`, wantHuman: true, wantExact: true}, |
| 32 | {name: "source", subject: "source ./script.sh", wantHuman: true, wantExact: true}, |
| 33 | {name: "dot source", subject: ". ./script.sh", wantHuman: true, wantExact: true}, |
| 34 | {name: "builtin eval", subject: `builtin eval "touch /tmp/x"`, wantHuman: true, wantExact: true}, |
| 35 | {name: "builtin source", subject: "builtin source ./script.sh", wantHuman: true, wantExact: true}, |
| 36 | {name: "bash command string", subject: `bash -lc "touch /tmp/x"`, wantHuman: true, wantExact: true}, |
| 37 | {name: "wrapped bash command string", subject: `env bash -c "touch /tmp/x"`, wantHuman: true, wantExact: true}, |
| 38 | {name: "powershell command string", subject: `pwsh -Command "New-Item x"`, wantHuman: true, wantExact: true}, |
| 39 | {name: "cmd command string", subject: `cmd /c "echo x > file"`, wantHuman: true, wantExact: true}, |
| 40 | {name: "python inline code", subject: `python3 -c "open('x','w').close()"`, wantHuman: true, wantExact: true}, |
| 41 | {name: "node inline code", subject: `node -e "require('fs').writeFileSync('x','')"`, wantHuman: true, wantExact: true}, |
| 42 | {name: "node attached inline code", subject: `node --eval="require('fs').writeFileSync('x','')"`, wantHuman: true, wantExact: true}, |
| 43 | {name: "ruby attached inline code", subject: `ruby -eFile.write('x','')`, wantHuman: true, wantExact: true}, |
| 44 | {name: "cmd attached command string", subject: `cmd /cecho x`, wantHuman: true, wantExact: true}, |
| 45 | {name: "find exec", subject: `find . -exec touch {} ;`, wantHuman: true, wantExact: true}, |
| 46 | } |
| 47 | for _, tt := range tests { |
| 48 | t.Run(tt.name, func(t *testing.T) { |
| 49 | if got := BashSubjectRequiresExplicitApproval(tt.subject); got != tt.wantHuman { |
| 50 | t.Errorf("BashSubjectRequiresExplicitApproval(%q) = %v, want %v", tt.subject, got, tt.wantHuman) |
| 51 | } |
| 52 | if got := bashSubjectRequiresExactRule(tt.subject); got != tt.wantExact { |
| 53 | t.Errorf("bashSubjectRequiresExactRule(%q) = %v, want %v", tt.subject, got, tt.wantExact) |
| 54 | } |
| 55 | }) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestPowerShellCmdletDenyPrefixIsCaseInsensitive(t *testing.T) { |
| 60 | p := New("allow", nil, nil, []string{ |
| 61 | "Set-Content", |
| 62 | "Bash(Add-Content:*)", |
| 63 | "Bash(Out-File:*)", |
| 64 | }) |
| 65 | for _, command := range []string{ |
| 66 | `set-content -LiteralPath app.go -Value bad`, |
| 67 | `ADD-CONTENT -LiteralPath app.go -Value bad`, |
| 68 | `out-file -FilePath app.go`, |
| 69 | } { |
| 70 | if got := p.DecideSubject("bash", false, command); got != Deny { |
| 71 | t.Fatalf("DecideSubject(%q) = %v, want Deny", command, got) |
| 72 | } |
| 73 | } |
| 74 | if got := p.DecideSubject("bash", false, `Set-Location src`); got != Allow { |
| 75 | t.Fatalf("unrelated PowerShell command = %v, want Allow", got) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestPolicyDynamicBashRequiresExplicitApproval(t *testing.T) { |
| 80 | const command = "git status $(touch /tmp/reasonix-permission-bypass)" |
| 81 | |
| 82 | tests := []struct { |
| 83 | name string |
| 84 | p Policy |
| 85 | want Decision |
| 86 | }{ |
| 87 | {name: "writer fallback allow cannot bypass", p: New("allow", nil, nil, nil), want: Ask}, |
| 88 | {name: "explicit dynamic fallback opt-in", p: New("allow", nil, nil, nil).WithAllowDynamicBashFallback(true), want: Allow}, |
| 89 | {name: "dynamic opt-in still requires allow fallback", p: New("ask", nil, nil, nil).WithAllowDynamicBashFallback(true), want: Ask}, |
| 90 | {name: "dynamic opt-in keeps ask precedence", p: New("allow", nil, []string{"Bash(git*)"}, nil).WithAllowDynamicBashFallback(true), want: Ask}, |
| 91 | {name: "dynamic opt-in keeps deny precedence", p: New("allow", nil, nil, []string{"Bash(git*)"}).WithAllowDynamicBashFallback(true), want: Deny}, |
| 92 | {name: "bare allow cannot bypass", p: New("ask", []string{"Bash"}, nil, nil), want: Ask}, |
| 93 | {name: "ordinary glob cannot bypass", p: New("ask", []string{"Bash(git*)"}, nil, nil), want: Ask}, |
| 94 | {name: "legacy prefix cannot bypass", p: New("ask", []string{"Bash(git *)"}, nil, nil), want: Ask}, |
| 95 | {name: "session glob cannot bypass", p: New("ask", nil, nil, nil).WithSessionAllow([]string{"Bash(git*)"}), want: Ask}, |
| 96 | {name: "explicit ask remains ask", p: New("allow", []string{"Bash"}, []string{"Bash(git*)"}, nil), want: Ask}, |
| 97 | {name: "raw deny wins", p: New("allow", []string{"Bash"}, nil, []string{"Bash(git*)"}), want: Deny}, |
| 98 | {name: "scoped raw deny wins", p: New("allow", []string{"Bash"}, nil, []string{"Bash(git status:*)"}), want: Deny}, |
| 99 | {name: "scoped raw ask remains ask", p: New("allow", []string{"Bash"}, []string{"Bash(git status:*)"}, nil), want: Ask}, |
| 100 | {name: "literal allow matches exactly", p: New("ask", []string{"Bash=" + command}, nil, nil), want: Allow}, |
| 101 | {name: "legacy exact allow matches exactly", p: New("ask", []string{"Bash(" + command + ")"}, nil, nil), want: Allow}, |
| 102 | {name: "literal session grant matches exactly", p: New("ask", nil, []string{"Bash(git*)"}, nil).WithSessionAllow([]string{"Bash=" + command}), want: Allow}, |
| 103 | } |
| 104 | for _, tt := range tests { |
| 105 | t.Run(tt.name, func(t *testing.T) { |
| 106 | if got := tt.p.DecideSubject("bash", false, command); got != tt.want { |
| 107 | t.Fatalf("DecideSubject(%q) = %v, want %v", command, got, tt.want) |
| 108 | } |
| 109 | }) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestPolicyRawBashPrefixMatchesDynamicSpacing(t *testing.T) { |
| 114 | command := "git status $(touch /tmp/x)" |
| 115 | if got := New("allow", nil, nil, []string{"Bash(git status:*)"}).DecideSubject("bash", false, command); got != Deny { |
| 116 | t.Fatalf("scoped deny with dynamic spacing = %v, want Deny", got) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestPolicyDynamicBashShapesRequireExplicitApproval(t *testing.T) { |
| 121 | p := New("allow", []string{"Bash"}, nil, nil) |
| 122 | for _, command := range []string{ |
| 123 | "git status `touch /tmp/x`", |
| 124 | "diff <(touch /tmp/x) expected", |
| 125 | "tee >(touch /tmp/x)", |
| 126 | `eval "touch /tmp/x"`, |
| 127 | "source ./script.sh", |
| 128 | `builtin eval "touch /tmp/x"`, |
| 129 | "builtin source ./script.sh", |
| 130 | `bash -c "touch /tmp/x"`, |
| 131 | `python3 -c "open('x','w').close()"`, |
| 132 | } { |
| 133 | if got := p.DecideSubject("bash", true, command); got != Ask { |
| 134 | t.Errorf("DecideSubject(%q) = %v, want Ask", command, got) |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestPolicyExactOnlyBashUsesFallbackWithoutReusableAllow(t *testing.T) { |
| 140 | for _, command := range []string{ |
| 141 | "git diff $REV", |
| 142 | "echo $((1 + 1))", |
| 143 | "REV=HEAD git diff", |
| 144 | "env REV=HEAD git diff", |
| 145 | "git status > status.txt", |
| 146 | "rm *.log", |
| 147 | "cat <<EOF\nhello\nEOF", |
| 148 | } { |
| 149 | if got := New("ask", []string{"Bash"}, nil, nil).DecideSubject("bash", false, command); got != Ask { |
| 150 | t.Errorf("ask fallback for %q = %v, want Ask", command, got) |
| 151 | } |
| 152 | if got := New("allow", []string{"Bash"}, nil, nil).DecideSubject("bash", false, command); got != Allow { |
| 153 | t.Errorf("auto fallback for %q = %v, want Allow", command, got) |
| 154 | } |
| 155 | if got := New("deny", []string{"Bash"}, nil, nil).DecideSubject("bash", false, command); got != Deny { |
| 156 | t.Errorf("deny fallback for %q = %v, want Deny", command, got) |
| 157 | } |
| 158 | if got := New("ask", []string{"Bash=" + command}, nil, nil).DecideSubject("bash", false, command); got != Allow { |
| 159 | t.Errorf("exact literal for %q = %v, want Allow", command, got) |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestPolicyStaticBashRulesRemainReusable(t *testing.T) { |
| 165 | tests := []struct { |
| 166 | rule string |
| 167 | command string |
| 168 | }{ |
| 169 | {rule: "Bash(git status:*)", command: "git status --short"}, |
| 170 | {rule: "Bash(git *)", command: "git status --short"}, |
| 171 | {rule: "Bash(git*)", command: "git status --short"}, |
| 172 | {rule: "Bash", command: "git status --short"}, |
| 173 | } |
| 174 | for _, tt := range tests { |
| 175 | p := New("ask", []string{tt.rule}, nil, nil) |
| 176 | if got := p.DecideSubject("bash", false, tt.command); got != Allow { |
| 177 | t.Errorf("rule %q command %q = %v, want Allow", tt.rule, tt.command, got) |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestDynamicBashRuleMatchingAndCoverage(t *testing.T) { |
| 183 | const command = "git status $(touch /tmp/x)" |
| 184 | if RuleMatchesString("Bash(git*)", "bash", command) { |
| 185 | t.Fatal("broad session allow matched dynamic command") |
| 186 | } |
| 187 | if !RuleMatchesString("Bash="+command, "bash", command) { |
| 188 | t.Fatal("literal session allow did not match exact dynamic command") |
| 189 | } |
| 190 | if RuleCoversString("Bash(git*)", "Bash="+command) { |
| 191 | t.Fatal("broad glob covered dynamic literal rule") |
| 192 | } |
| 193 | if RuleCoversString("Bash", "Bash="+command) { |
| 194 | t.Fatal("bare Bash rule covered dynamic literal rule") |
| 195 | } |
| 196 | if !RuleCoversString("Bash="+command, "Bash="+command) { |
| 197 | t.Fatal("identical dynamic literal rules were not deduplicated") |
| 198 | } |
| 199 | if !RuleCoversString("Bash", "Bash") { |
| 200 | t.Fatal("identical bare rules were not deduplicated") |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestDynamicBashRememberedAsLiteral(t *testing.T) { |
| 205 | commands := []string{ |
| 206 | "git status $(touch /tmp/x)", |
| 207 | "rm *.log", |
| 208 | `eval "touch /tmp/x"`, |
| 209 | } |
| 210 | for _, command := range commands { |
| 211 | want := "Bash=" + command |
| 212 | if got := RememberRuleForScope("bash", command); got != want { |
| 213 | t.Errorf("RememberRuleForScope(%q) = %q, want %q", command, got, want) |
| 214 | } |
| 215 | if got := SessionGrantRuleForScope("bash", command); got != want { |
| 216 | t.Errorf("SessionGrantRuleForScope(%q) = %q, want %q", command, got, want) |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 |