| 1 | package shellsafe |
| 2 | |
| 3 | import "testing" |
| 4 | |
| 5 | func TestCommandIsReadOnly(t *testing.T) { |
| 6 | readOnly := []string{ |
| 7 | // git read-only subcommands (the #5341 set, beyond status/diff/log/show). |
| 8 | "git status", "git diff HEAD", "git log --oneline", "git show", |
| 9 | "git rev-parse HEAD", "git describe --tags", "git reflog", |
| 10 | "git for-each-ref", "git cat-file -p HEAD", "git ls-tree HEAD", |
| 11 | "git rev-list --count HEAD", "git shortlog", "git name-rev HEAD", |
| 12 | `git log "2>/dev/null"`, `git log 2\>/dev/null`, |
| 13 | // general read-only commands. |
| 14 | "ls -la", "cat go.mod", "grep -r foo .", "pwd", "head -n5 x", "stat x", "du -sh .", |
| 15 | `grep 'a|b' file`, `printf "%s\n" "a && b"`, |
| 16 | // tooling probes. |
| 17 | "go version", "go env", "go list ./...", "go doc fmt", |
| 18 | "npm view react version", "npm outdated", "cargo check", |
| 19 | "docker ps", "docker images", "kubectl get pods", |
| 20 | "node -v", "node --version", "python --version", "python3 --version", |
| 21 | // PowerShell permission-safe inspection commands. |
| 22 | `Get-Process -Name mongod`, `Get-ChildItem -Path .`, |
| 23 | `Get-NetTCPConnection -LocalPort 6379`, `Resolve-Path .`, |
| 24 | // Narrow, recursively proven read-only command substitution. |
| 25 | `basename "$(pwd)"`, `dirname "$(realpath .)"`, |
| 26 | } |
| 27 | for _, c := range readOnly { |
| 28 | if _, _, ok := CommandIsReadOnly(c); !ok { |
| 29 | t.Errorf("CommandIsReadOnly(%q) = false, want true", c) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | notReadOnly := []string{ |
| 34 | // write-capable commands / subcommands. |
| 35 | "rm -rf /", "git push", "git commit -m x", "git checkout main", |
| 36 | "git reset --hard", "git branch -d feature", "git remote add o url", |
| 37 | "go build ./...", "go test ./...", "npm install", "docker rm x", |
| 38 | "kubectl apply -f x.yaml", "mv a b", "chmod 777 x", |
| 39 | // shell syntax can smuggle a write past a read-only base word. |
| 40 | "git status && rm -rf /", "cat a | tee b", "echo $(rm x)", |
| 41 | "git status > out.txt", "ls; rm x", "git log `whoami`", "echo $HOME", |
| 42 | `basename "$(touch out)"`, `basename "$(pwd; touch out)"`, |
| 43 | `basename "$(date --set tomorrow)"`, `basename "$(find . -delete)"`, |
| 44 | `basename $(pwd)`, `basename "$HOME"`, `find . "$(printf -- -delete)"`, |
| 45 | // unknown command. |
| 46 | "frobnicate --all", |
| 47 | // Network probes do not write the workspace, but they are not safe to |
| 48 | // auto-allow through the permission-layer read-only classifier. |
| 49 | `Test-NetConnection -ComputerName example.com -Port 443`, |
| 50 | // PowerShell mutators stay fail-closed. |
| 51 | `Start-Process mongod`, `Stop-Process -Name mongod`, |
| 52 | `Set-Content style.css bad`, `Remove-Item style.css`, |
| 53 | } |
| 54 | for _, c := range notReadOnly { |
| 55 | if _, _, ok := CommandIsReadOnly(c); ok { |
| 56 | t.Errorf("CommandIsReadOnly(%q) = true, want false", c) |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func TestCommandIsWorkspaceNonMutatingKeepsNetworkProbeOutOfPermissionReaders(t *testing.T) { |
| 62 | tests := []struct { |
| 63 | command string |
| 64 | want bool |
| 65 | }{ |
| 66 | {command: `Test-NetConnection -ComputerName example.com -Port 443`, want: true}, |
| 67 | {command: `Get-Process -Name mongod`, want: true}, |
| 68 | {command: `Test-NetConnection example.com; Set-Content out.txt bad`}, |
| 69 | {command: `Set-Content out.txt bad`}, |
| 70 | } |
| 71 | for _, tt := range tests { |
| 72 | t.Run(tt.command, func(t *testing.T) { |
| 73 | _, _, got := CommandIsWorkspaceNonMutating(tt.command) |
| 74 | if got != tt.want { |
| 75 | t.Fatalf("CommandIsWorkspaceNonMutating(%q) = %t, want %t", tt.command, got, tt.want) |
| 76 | } |
| 77 | }) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | func TestContainsShellSyntax(t *testing.T) { |
| 82 | for _, c := range []string{"a && b", "a || b", "a | b", "a; b", "a > f", "a < f", "a & ", "$(x)", "`x`", "a\nb"} { |
| 83 | if !ContainsShellSyntax(c) { |
| 84 | t.Errorf("ContainsShellSyntax(%q) = false, want true", c) |
| 85 | } |
| 86 | } |
| 87 | for _, c := range []string{"git status", "ls -la", "grep foo bar.go", `grep 'a|b' file`, `echo "a && b"`} { |
| 88 | if ContainsShellSyntax(c) { |
| 89 | t.Errorf("ContainsShellSyntax(%q) = true, want false", c) |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 |