Skip to main content

safe_chains/handlers/
mod.rs

1pub mod android;
2pub mod coreutils;
3pub mod forges;
4pub mod fuzzy;
5pub mod jvm;
6pub mod network;
7pub mod node;
8pub mod perl;
9pub mod ruby;
10pub mod shell;
11pub mod system;
12pub mod vcs;
13pub mod wrappers;
14
15
16use std::collections::HashMap;
17
18use crate::parse::Token;
19use crate::verdict::Verdict;
20
21type HandlerFn = fn(&[Token]) -> Verdict;
22
23pub fn custom_cmd_handlers() -> HashMap<&'static str, HandlerFn> {
24    HashMap::from([
25        ("sysctl", system::sysctl::is_safe_sysctl as HandlerFn),
26    ])
27}
28
29pub fn custom_sub_handlers() -> HashMap<&'static str, HandlerFn> {
30    HashMap::from([
31        ("bun_x", node::bun::check_bun_x as HandlerFn),
32        ("bundle_config", ruby::bundle::check_bundle_config as HandlerFn),
33        ("bundle_exec", ruby::bundle::check_bundle_exec as HandlerFn),
34        ("git_remote", vcs::git::check_git_remote as HandlerFn),
35    ])
36}
37
38pub fn dispatch(tokens: &[Token]) -> Verdict {
39    let cmd = tokens[0].command_name();
40    None
41        .or_else(|| shell::dispatch(cmd, tokens))
42        .or_else(|| wrappers::dispatch(cmd, tokens))
43        .or_else(|| forges::dispatch(cmd, tokens))
44        .or_else(|| node::dispatch(cmd, tokens))
45        .or_else(|| jvm::dispatch(cmd, tokens))
46        .or_else(|| android::dispatch(cmd, tokens))
47        .or_else(|| network::dispatch(cmd, tokens))
48        .or_else(|| system::dispatch(cmd, tokens))
49        .or_else(|| perl::dispatch(cmd, tokens))
50        .or_else(|| coreutils::dispatch(cmd, tokens))
51        .or_else(|| fuzzy::dispatch(cmd, tokens))
52        .or_else(|| crate::registry::toml_dispatch(tokens))
53        .unwrap_or(Verdict::Denied)
54}
55
56#[cfg(test)]
57const HANDLED_CMDS: &[&str] = &[
58    "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv",
59    "git", "jj", "gh", "glab", "jjpr", "tea",
60    "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta", "mocha",
61    "ruby", "ri", "bundle", "gem", "importmap", "rbenv",
62    "pip", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
63    "cargo", "rustup",
64    "go",
65    "gradle", "mvn", "mvnw", "ktlint", "detekt",
66    "javap", "jar", "keytool", "jarsigner",
67    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
68    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
69    "fastlane", "firebase",
70    "composer", "craft",
71    "swift",
72    "dotnet",
73    "curl",
74    "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
75    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
76    "ddev", "dcli",
77    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
78    "pg_dump", "bazel", "meson", "ninja",
79    "terraform", "heroku", "vercel", "flyctl", "pulumi", "netlify", "railway", "wrangler", "cf", "newrelic",
80    "aws", "gcloud", "az",
81    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
82    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
83    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl",
84    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
85    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
86    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
87    "perl",
88    "R", "Rscript",
89    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
90    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
91    "diff", "comm", "paste", "tac", "rev", "nl",
92    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
93    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat",
94    "arch", "command", "hostname",
95    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
96    "magick",
97    "fd", "eza", "exa", "ls", "delta", "colordiff",
98    "dirname", "basename", "realpath", "readlink",
99    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
100    "true", "false",
101    "alias", "export", "printenv", "read", "type", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
102    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
103    "who", "w", "last", "lastlog",
104    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "lsblk", "free",
105    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
106    "base64", "xxd", "getconf", "uuidgen",
107    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
108    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
109    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
110    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
111    "traceroute", "traceroute6", "mtr",
112    "xv",
113    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
114    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
115    "tldr", "ldd", "objdump", "readelf", "just",
116    "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
117    "rubocop", "eslint", "biome", "stylelint", "zoxide",
118    "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
119    "direnv", "make", "packer", "vagrant",
120    "node", "python3", "python", "rustc", "java", "php",
121    "gcc", "g++", "cc", "c++", "clang", "clang++",
122    "elixir", "erl", "mix", "zig", "lua", "tsc",
123    "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
124    "git-lfs", "tig",
125    "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
126];
127
128pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
129    let mut docs = Vec::new();
130    docs.extend(forges::command_docs());
131    docs.extend(node::command_docs());
132    docs.extend(jvm::command_docs());
133    docs.extend(android::command_docs());
134    docs.extend(network::command_docs());
135    docs.extend(system::command_docs());
136    docs.extend(perl::command_docs());
137    docs.extend(coreutils::command_docs());
138    docs.extend(fuzzy::command_docs());
139    docs.extend(shell::command_docs());
140    docs.extend(wrappers::command_docs());
141    docs.extend(crate::registry::toml_command_docs());
142    docs
143}
144
145#[cfg(test)]
146#[derive(Debug)]
147pub(crate) enum CommandEntry {
148    Positional { cmd: &'static str },
149    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
150    Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
151    Delegation { cmd: &'static str },
152}
153
154#[cfg(test)]
155#[derive(Debug)]
156pub(crate) enum SubEntry {
157    Policy { name: &'static str },
158    Nested { name: &'static str, subs: &'static [SubEntry] },
159    Custom { name: &'static str, valid_suffix: Option<&'static str> },
160    Positional,
161    Guarded { name: &'static str, valid_suffix: &'static str },
162}
163
164pub fn all_opencode_patterns() -> Vec<String> {
165    let mut patterns = Vec::new();
166    patterns.sort();
167    patterns.dedup();
168    patterns
169}
170
171#[cfg(test)]
172fn full_registry() -> Vec<&'static CommandEntry> {
173    let mut entries = Vec::new();
174    entries.extend(shell::REGISTRY);
175    entries.extend(wrappers::REGISTRY);
176    entries.extend(forges::full_registry());
177    entries.extend(node::full_registry());
178    entries.extend(jvm::full_registry());
179    entries.extend(android::full_registry());
180    entries.extend(network::REGISTRY);
181    entries.extend(system::full_registry());
182    entries.extend(perl::REGISTRY);
183    entries.extend(coreutils::full_registry());
184    entries.extend(fuzzy::full_registry());
185    entries
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use std::collections::HashSet;
192
193    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
194    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
195
196    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
197        match entry {
198            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
199            CommandEntry::Custom { cmd, valid_prefix } => {
200                let base = valid_prefix.unwrap_or(cmd);
201                let test = format!("{base} {UNKNOWN_FLAG}");
202                if crate::is_safe_command(&test) {
203                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
204                }
205            }
206            CommandEntry::Subcommand { cmd, subs, bare_ok } => {
207                if !bare_ok && crate::is_safe_command(cmd) {
208                    failures.push(format!("{cmd}: accepted bare invocation"));
209                }
210                let test = format!("{cmd} {UNKNOWN_SUB}");
211                if crate::is_safe_command(&test) {
212                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
213                }
214                for sub in *subs {
215                    check_sub(cmd, sub, failures);
216                }
217            }
218        }
219    }
220
221    fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
222        match entry {
223            SubEntry::Policy { name } => {
224                let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
225                if crate::is_safe_command(&test) {
226                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
227                }
228            }
229            SubEntry::Nested { name, subs } => {
230                let path = format!("{prefix} {name}");
231                let test = format!("{path} {UNKNOWN_SUB}");
232                if crate::is_safe_command(&test) {
233                    failures.push(format!("{path}: accepted unknown subcommand: {test}"));
234                }
235                for sub in *subs {
236                    check_sub(&path, sub, failures);
237                }
238            }
239            SubEntry::Custom { name, valid_suffix } => {
240                let base = match valid_suffix {
241                    Some(s) => format!("{prefix} {name} {s}"),
242                    None => format!("{prefix} {name}"),
243                };
244                let test = format!("{base} {UNKNOWN_FLAG}");
245                if crate::is_safe_command(&test) {
246                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
247                }
248            }
249            SubEntry::Positional => {}
250            SubEntry::Guarded { name, valid_suffix } => {
251                let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
252                if crate::is_safe_command(&test) {
253                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
254                }
255            }
256        }
257    }
258
259    #[test]
260    fn all_commands_reject_unknown() {
261        let registry = full_registry();
262        let mut failures = Vec::new();
263        for entry in &registry {
264            check_entry(entry, &mut failures);
265        }
266        assert!(
267            failures.is_empty(),
268            "unknown flags/subcommands accepted:\n{}",
269            failures.join("\n")
270        );
271    }
272
273    #[test]
274    fn process_substitution_blocked() {
275        let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
276        for cmd in &cmds {
277            assert!(
278                !crate::is_safe_command(cmd),
279                "process substitution not blocked: {cmd}",
280            );
281        }
282    }
283
284    #[test]
285    fn registry_covers_handled_commands() {
286        let registry = full_registry();
287        let mut all_cmds: HashSet<&str> = registry
288            .iter()
289            .map(|e| match e {
290                CommandEntry::Positional { cmd }
291                | CommandEntry::Custom { cmd, .. }
292                | CommandEntry::Subcommand { cmd, .. }
293                | CommandEntry::Delegation { cmd } => *cmd,
294            })
295            .collect();
296        for name in crate::registry::toml_command_names() {
297            all_cmds.insert(name);
298        }
299        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
300
301        let missing: Vec<_> = handled.difference(&all_cmds).collect();
302        assert!(missing.is_empty(), "not in registry: {missing:?}");
303
304        let extra: Vec<_> = all_cmds.difference(&handled).collect();
305        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
306    }
307
308}