| 1 | package permission |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | |
| 6 | "reasonix/internal/shellparse" |
| 7 | ) |
| 8 | |
| 9 | type bashApprovalClass uint8 |
| 10 | |
| 11 | const ( |
| 12 | bashApprovalReusable bashApprovalClass = iota |
| 13 | bashApprovalExactOnly |
| 14 | bashApprovalRequireHuman |
| 15 | ) |
| 16 | |
| 17 | // BashSubjectRequiresExplicitApproval reports whether subject can execute a |
| 18 | // nested or indirect command and therefore needs a human in Ask/Auto. Exact |
| 19 | // command rules are handled separately by Policy before this classification. |
| 20 | func BashSubjectRequiresExplicitApproval(subject string) bool { |
| 21 | return classifyBashApproval(subject) == bashApprovalRequireHuman |
| 22 | } |
| 23 | |
| 24 | func bashSubjectRequiresExactRule(subject string) bool { |
| 25 | return classifyBashApproval(subject) != bashApprovalReusable |
| 26 | } |
| 27 | |
| 28 | func classifyBashApproval(subject string) bashApprovalClass { |
| 29 | if strings.TrimSpace(subject) == "" { |
| 30 | return bashApprovalReusable |
| 31 | } |
| 32 | segments, _, ok := shellparse.SplitTopLevel(subject) |
| 33 | if !ok { |
| 34 | return classifyBashSegmentApproval(subject) |
| 35 | } |
| 36 | if len(segments) == 0 { |
| 37 | return bashApprovalRequireHuman |
| 38 | } |
| 39 | class := bashApprovalReusable |
| 40 | for _, segment := range segments { |
| 41 | segmentClass := classifyBashSegmentApproval(segment) |
| 42 | if segmentClass > class { |
| 43 | class = segmentClass |
| 44 | } |
| 45 | if class == bashApprovalRequireHuman { |
| 46 | break |
| 47 | } |
| 48 | } |
| 49 | return class |
| 50 | } |
| 51 | |
| 52 | func classifyBashSegmentApproval(subject string) bashApprovalClass { |
| 53 | if normalized, ok := normalizeBashSafeRedirectsForMatch(subject); ok { |
| 54 | subject = normalized |
| 55 | } |
| 56 | features, ok := shellparse.AnalyzeApprovalFeatures(subject) |
| 57 | if !ok || features.NestedExecution || features.DynamicCommandName { |
| 58 | return bashApprovalRequireHuman |
| 59 | } |
| 60 | if len(features.CommandPrefix) > 0 && isIndirectExecution(features.CommandPrefix) { |
| 61 | return bashApprovalRequireHuman |
| 62 | } |
| 63 | if features.Expansion || features.Assignment || features.Redirection || |
| 64 | shellparse.ContainsUnquotedGlob(subject) || hasEnvWrapperAssignment(features.CommandPrefix) { |
| 65 | return bashApprovalExactOnly |
| 66 | } |
| 67 | return bashApprovalReusable |
| 68 | } |
| 69 | |
| 70 | func isIndirectExecution(fields []string) bool { |
| 71 | if len(fields) == 0 { |
| 72 | return true |
| 73 | } |
| 74 | base := executableBase(fields[0]) |
| 75 | args := fields[1:] |
| 76 | |
| 77 | switch base { |
| 78 | case "eval", "source", ".", "xargs": |
| 79 | return true |
| 80 | case "env": |
| 81 | for len(args) > 0 && isEnvironmentAssignment(args[0]) { |
| 82 | args = args[1:] |
| 83 | } |
| 84 | if len(args) == 0 || strings.HasPrefix(args[0], "-") { |
| 85 | return true |
| 86 | } |
| 87 | return isIndirectExecution(args) |
| 88 | case "builtin", "command", "exec", "nohup", "sudo": |
| 89 | if len(args) == 0 || strings.HasPrefix(args[0], "-") { |
| 90 | return true |
| 91 | } |
| 92 | return isIndirectExecution(args) |
| 93 | case "bash", "dash", "fish", "ksh", "sh", "zsh": |
| 94 | return hasShellCommandFlag(args) |
| 95 | case "powershell", "pwsh": |
| 96 | return hasAnyFoldedArg(args, "-c", "-command", "-e", "-enc", "-encodedcommand") |
| 97 | case "cmd": |
| 98 | return hasAnyFoldedArg(args, "/c", "/k") |
| 99 | case "node", "bun": |
| 100 | return hasAnyFoldedArg(args, "-e", "--eval", "-p", "--print") |
| 101 | case "deno": |
| 102 | return hasAnyFoldedArg(args, "eval") |
| 103 | case "python", "python3", "py", "pypy", "pypy3": |
| 104 | return hasAnyFoldedArg(args, "-c") |
| 105 | case "perl", "ruby", "lua", "luajit", "r", "rscript", "osascript": |
| 106 | return hasAnyFoldedArg(args, "-e") |
| 107 | case "php": |
| 108 | return hasAnyFoldedArg(args, "-r") |
| 109 | case "find": |
| 110 | return hasAnyFoldedArg(args, "-exec", "-execdir", "-ok", "-okdir") |
| 111 | default: |
| 112 | return false |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func hasEnvWrapperAssignment(fields []string) bool { |
| 117 | if len(fields) < 2 || executableBase(fields[0]) != "env" { |
| 118 | return false |
| 119 | } |
| 120 | for _, arg := range fields[1:] { |
| 121 | if isEnvironmentAssignment(arg) { |
| 122 | return true |
| 123 | } |
| 124 | if !strings.HasPrefix(arg, "-") { |
| 125 | return false |
| 126 | } |
| 127 | } |
| 128 | return false |
| 129 | } |
| 130 | |
| 131 | func executableBase(command string) string { |
| 132 | if i := strings.LastIndexAny(command, `/\\`); i >= 0 { |
| 133 | command = command[i+1:] |
| 134 | } |
| 135 | command = strings.ToLower(command) |
| 136 | return strings.TrimSuffix(command, ".exe") |
| 137 | } |
| 138 | |
| 139 | func hasShellCommandFlag(args []string) bool { |
| 140 | for _, arg := range args { |
| 141 | lower := strings.ToLower(arg) |
| 142 | if lower == "--" { |
| 143 | return false |
| 144 | } |
| 145 | if lower == "--command" { |
| 146 | return true |
| 147 | } |
| 148 | if strings.HasPrefix(lower, "-") && !strings.HasPrefix(lower, "--") && strings.Contains(lower[1:], "c") { |
| 149 | return true |
| 150 | } |
| 151 | } |
| 152 | return false |
| 153 | } |
| 154 | |
| 155 | func hasAnyFoldedArg(args []string, candidates ...string) bool { |
| 156 | for _, arg := range args { |
| 157 | lower := strings.ToLower(arg) |
| 158 | for _, candidate := range candidates { |
| 159 | candidate = strings.ToLower(candidate) |
| 160 | if lower == candidate { |
| 161 | return true |
| 162 | } |
| 163 | if strings.HasPrefix(candidate, "--") && (strings.HasPrefix(lower, candidate+"=") || strings.HasPrefix(lower, candidate+":")) { |
| 164 | return true |
| 165 | } |
| 166 | if strings.HasPrefix(candidate, "-") && !strings.HasPrefix(candidate, "--") && len(candidate) == 2 && strings.HasPrefix(lower, candidate) && !strings.HasPrefix(lower, "--") { |
| 167 | return true |
| 168 | } |
| 169 | if strings.HasPrefix(candidate, "/") && len(candidate) == 2 && strings.HasPrefix(lower, candidate) { |
| 170 | return true |
| 171 | } |
| 172 | if len(candidate) > 2 && strings.HasPrefix(candidate, "-") && strings.HasPrefix(lower, candidate+":") { |
| 173 | return true |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | return false |
| 178 | } |
| 179 | |
| 180 | func isEnvironmentAssignment(arg string) bool { |
| 181 | name, _, ok := strings.Cut(arg, "=") |
| 182 | if !ok || name == "" { |
| 183 | return false |
| 184 | } |
| 185 | for i, r := range name { |
| 186 | letter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') |
| 187 | digit := i > 0 && r >= '0' && r <= '9' |
| 188 | if !letter && !digit && r != '_' { |
| 189 | return false |
| 190 | } |
| 191 | } |
| 192 | return true |
| 193 | } |
| 194 |