| 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 | {name: "awk inline system call", subject: `awk 'BEGIN{system("touch /tmp/x")}'`, wantHuman: true, wantExact: true}, |
| 47 | {name: "awk inline getline pipe", subject: `awk '{"touch /tmp/x" | getline line}' file`, wantHuman: true, wantExact: true}, |
| 48 | {name: "awk variant inline program", subject: `gawk '{print $1}' file`, wantHuman: true, wantExact: true}, |
| 49 | {name: "awk field separator is not a script file", subject: `awk -F: 'BEGIN{system("touch /tmp/x")}' /etc/passwd`, wantHuman: true, wantExact: true}, |
| 50 | {name: "awk separate field separator is not a script file", subject: `awk -F : 'BEGIN{system("touch /tmp/x")}' /etc/passwd`, wantHuman: true, wantExact: true}, |
| 51 | {name: "gawk inline source overrides script file", subject: `gawk -f safe.awk -e 'BEGIN{system("touch /tmp/x")}'`, wantHuman: true, wantExact: true}, |
| 52 | {name: "gawk long inline source overrides script file", subject: `gawk --file=safe.awk --source='BEGIN{system("touch /tmp/x")}'`, wantHuman: true, wantExact: true}, |
| 53 | {name: "awk script file", subject: `awk -f transform.awk input.txt`}, |
| 54 | {name: "awk attached script file", subject: `mawk -ftransform.awk input.txt`}, |
| 55 | {name: "awk long script file", subject: `awk --file=transform.awk input.txt`}, |
| 56 | {name: "gawk exec script file", subject: `gawk -E transform.awk input.txt`}, |
| 57 | {name: "gawk long exec script file", subject: `gawk --exec=transform.awk input.txt`}, |
| 58 | } |
| 59 | for _, tt := range tests { |
| 60 | t.Run(tt.name, func(t *testing.T) { |
| 61 | if got := BashSubjectRequiresExplicitApproval(tt.subject); got != tt.wantHuman { |
| 62 | t.Errorf("BashSubjectRequiresExplicitApproval(%q) = %v, want %v", tt.subject, got, tt.wantHuman) |
| 63 | } |
| 64 | if got := bashSubjectRequiresExactRule(tt.subject); got != tt.wantExact { |
| 65 | t.Errorf("bashSubjectRequiresExactRule(%q) = %v, want %v", tt.subject, got, tt.wantExact) |
| 66 | } |
| 67 | }) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestPowerShellCmdletDenyPrefixIsCaseInsensitive(t *testing.T) { |
| 72 | p := New("allow", nil, nil, []string{ |
| 73 | "Set-Content", |
| 74 | "Bash(Add-Content:*)", |
| 75 | "Bash(Out-File:*)", |
| 76 | }) |
| 77 | for _, command := range []string{ |
| 78 | `set-content -LiteralPath app.go -Value bad`, |
| 79 | `ADD-CONTENT -LiteralPath app.go -Value bad`, |
| 80 | `out-file -FilePath app.go`, |
| 81 | } { |
| 82 | if got := p.DecideSubject("bash", false, command); got != Deny { |
| 83 | t.Fatalf("DecideSubject(%q) = %v, want Deny", command, got) |
| 84 | } |
| 85 | } |
| 86 | if got := p.DecideSubject("bash", false, `Set-Location src`); got != Allow { |
| 87 | t.Fatalf("unrelated PowerShell command = %v, want Allow", got) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestPolicyDynamicBashFollowsPresetAndExplicitRules(t *testing.T) { |
| 92 | const command = "git status $(touch /tmp/reasonix-permission-bypass)" |
| 93 | |
| 94 | tests := []struct { |
| 95 | name string |
| 96 | p Policy |
| 97 | want Decision |
| 98 | }{ |
| 99 | {name: "workspace fallback allows inside sandbox", p: New("allow", nil, nil, nil), want: Allow}, |
| 100 | {name: "explicit dynamic fallback opt-in", p: New("allow", nil, nil, nil).WithAllowDynamicBashFallback(true), want: Allow}, |
| 101 | {name: "dynamic opt-in still requires allow fallback", p: New("ask", nil, nil, nil).WithAllowDynamicBashFallback(true), want: Ask}, |
| 102 | {name: "dynamic opt-in keeps ask precedence", p: New("allow", nil, []string{"Bash(git*)"}, nil).WithAllowDynamicBashFallback(true), want: Ask}, |
| 103 | {name: "dynamic opt-in keeps deny precedence", p: New("allow", nil, nil, []string{"Bash(git*)"}).WithAllowDynamicBashFallback(true), want: Deny}, |
| 104 | {name: "bare allow remains bounded by sandbox", p: New("ask", []string{"Bash"}, nil, nil), want: Ask}, |
| 105 | {name: "ordinary glob does not grant an indirect command", p: New("ask", []string{"Bash(git*)"}, nil, nil), want: Ask}, |
| 106 | {name: "legacy prefix does not grant an indirect command", p: New("ask", []string{"Bash(git *)"}, nil, nil), want: Ask}, |
| 107 | {name: "session glob cannot bypass", p: New("ask", nil, nil, nil).WithSessionAllow([]string{"Bash(git*)"}), want: Ask}, |
| 108 | {name: "explicit ask remains ask", p: New("allow", []string{"Bash"}, []string{"Bash(git*)"}, nil), want: Ask}, |
| 109 | {name: "raw deny wins", p: New("allow", []string{"Bash"}, nil, []string{"Bash(git*)"}), want: Deny}, |
| 110 | {name: "scoped raw deny wins", p: New("allow", []string{"Bash"}, nil, []string{"Bash(git status:*)"}), want: Deny}, |
| 111 | {name: "scoped raw ask remains ask", p: New("allow", []string{"Bash"}, []string{"Bash(git status:*)"}, nil), want: Ask}, |
| 112 | {name: "literal allow matches exactly", p: New("ask", []string{"Bash=" + command}, nil, nil), want: Allow}, |
| 113 | {name: "legacy exact allow matches exactly", p: New("ask", []string{"Bash(" + command + ")"}, nil, nil), want: Allow}, |
| 114 | {name: "literal session grant matches exactly", p: New("ask", nil, []string{"Bash(git*)"}, nil).WithSessionAllow([]string{"Bash=" + command}), want: Allow}, |
| 115 | } |
| 116 | for _, tt := range tests { |
| 117 | t.Run(tt.name, func(t *testing.T) { |
| 118 | if got := tt.p.DecideSubject("bash", false, command); got != tt.want { |
| 119 | t.Fatalf("DecideSubject(%q) = %v, want %v", command, got, tt.want) |
| 120 | } |
| 121 | }) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestPolicyRawBashPrefixMatchesDynamicSpacing(t *testing.T) { |
| 126 | command := "git status $(touch /tmp/x)" |
| 127 | if got := New("allow", nil, nil, []string{"Bash(git status:*)"}).DecideSubject("bash", false, command); got != Deny { |
| 128 | t.Fatalf("scoped deny with dynamic spacing = %v, want Deny", got) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func TestPolicyDynamicBashShapesFollowWorkspaceFallback(t *testing.T) { |
| 133 | p := New("allow", []string{"Bash"}, nil, nil) |
| 134 | for _, command := range []string{ |
| 135 | "git status `touch /tmp/x`", |
| 136 | "diff <(touch /tmp/x) expected", |
| 137 | "tee >(touch /tmp/x)", |
| 138 | `eval "touch /tmp/x"`, |
| 139 | "source ./script.sh", |
| 140 | `builtin eval "touch /tmp/x"`, |
| 141 | "builtin source ./script.sh", |
| 142 | `bash -c "touch /tmp/x"`, |
| 143 | `python3 -c "open('x','w').close()"`, |
| 144 | } { |
| 145 | if got := p.DecideSubject("bash", true, command); got != Allow { |
| 146 | t.Errorf("DecideSubject(%q) = %v, want Allow", command, got) |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestPolicyExactOnlyBashUsesFallbackWithoutReusableAllow(t *testing.T) { |
| 152 | for _, command := range []string{ |
| 153 | "git diff $REV", |
| 154 | "echo $((1 + 1))", |
| 155 | "REV=HEAD git diff", |
| 156 | "env REV=HEAD git diff", |
| 157 | "git status > status.txt", |
| 158 | "rm *.log", |
| 159 | "cat <<EOF\nhello\nEOF", |
| 160 | } { |
| 161 | if got := New("ask", []string{"Bash"}, nil, nil).DecideSubject("bash", false, command); got != Ask { |
| 162 | t.Errorf("ask fallback for %q = %v, want Ask", command, got) |
| 163 | } |
| 164 | if got := New("allow", []string{"Bash"}, nil, nil).DecideSubject("bash", false, command); got != Allow { |
| 165 | t.Errorf("auto fallback for %q = %v, want Allow", command, got) |
| 166 | } |
| 167 | if got := New("deny", []string{"Bash"}, nil, nil).DecideSubject("bash", false, command); got != Deny { |
| 168 | t.Errorf("deny fallback for %q = %v, want Deny", command, got) |
| 169 | } |
| 170 | if got := New("ask", []string{"Bash=" + command}, nil, nil).DecideSubject("bash", false, command); got != Allow { |
| 171 | t.Errorf("exact literal for %q = %v, want Allow", command, got) |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestPolicyStaticBashRulesRemainReusable(t *testing.T) { |
| 177 | tests := []struct { |
| 178 | rule string |
| 179 | command string |
| 180 | }{ |
| 181 | {rule: "Bash(git status:*)", command: "git status --short"}, |
| 182 | {rule: "Bash(git *)", command: "git status --short"}, |
| 183 | {rule: "Bash(git*)", command: "git status --short"}, |
| 184 | {rule: "Bash", command: "git status --short"}, |
| 185 | } |
| 186 | for _, tt := range tests { |
| 187 | p := New("ask", []string{tt.rule}, nil, nil) |
| 188 | if got := p.DecideSubject("bash", false, tt.command); got != Allow { |
| 189 | t.Errorf("rule %q command %q = %v, want Allow", tt.rule, tt.command, got) |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | func TestDynamicBashRuleMatchingAndCoverage(t *testing.T) { |
| 195 | const command = "git status $(touch /tmp/x)" |
| 196 | if RuleMatchesString("Bash(git*)", "bash", command) { |
| 197 | t.Fatal("broad session allow matched dynamic command") |
| 198 | } |
| 199 | if !RuleMatchesString("Bash="+command, "bash", command) { |
| 200 | t.Fatal("literal session allow did not match exact dynamic command") |
| 201 | } |
| 202 | if RuleCoversString("Bash(git*)", "Bash="+command) { |
| 203 | t.Fatal("broad glob covered dynamic literal rule") |
| 204 | } |
| 205 | if RuleCoversString("Bash", "Bash="+command) { |
| 206 | t.Fatal("bare Bash rule covered dynamic literal rule") |
| 207 | } |
| 208 | if !RuleCoversString("Bash="+command, "Bash="+command) { |
| 209 | t.Fatal("identical dynamic literal rules were not deduplicated") |
| 210 | } |
| 211 | if !RuleCoversString("Bash", "Bash") { |
| 212 | t.Fatal("identical bare rules were not deduplicated") |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | func TestDynamicBashRememberedAsLiteral(t *testing.T) { |
| 217 | commands := []string{ |
| 218 | "git status $(touch /tmp/x)", |
| 219 | "rm *.log", |
| 220 | `eval "touch /tmp/x"`, |
| 221 | } |
| 222 | for _, command := range commands { |
| 223 | want := "Bash=" + command |
| 224 | if got := RememberRuleForScope("bash", command); got != want { |
| 225 | t.Errorf("RememberRuleForScope(%q) = %q, want %q", command, got, want) |
| 226 | } |
| 227 | if got := SessionGrantRuleForScope("bash", command); got != want { |
| 228 | t.Errorf("SessionGrantRuleForScope(%q) = %q, want %q", command, got, want) |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 |