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",
61    "ruby", "ri", "bundle", "gem", "importmap", "rbenv",
62    "pip", "uv", "poetry", "pyenv", "conda",
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",
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    "terraform", "heroku", "vercel", "flyctl", "pulumi", "netlify", "railway", "wrangler", "cf", "newrelic",
79    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
80    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
81    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl",
82    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
83    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
84    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
85    "perl",
86    "R", "Rscript",
87    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
88    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
89    "diff", "comm", "paste", "tac", "rev", "nl",
90    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
91    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat",
92    "arch", "command", "hostname",
93    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
94    "magick",
95    "fd", "eza", "exa", "ls", "delta", "colordiff",
96    "dirname", "basename", "realpath", "readlink",
97    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
98    "true", "false",
99    "alias", "export", "printenv", "read", "type", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
100    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
101    "who", "w", "last", "lastlog",
102    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "lsblk", "free",
103    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
104    "base64", "xxd", "getconf", "uuidgen",
105    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
106    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
107    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
108    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
109    "xv",
110    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
111    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "workon", "safe-chains",
112    "tldr", "ldd", "objdump", "readelf", "just",
113    "direnv", "make", "packer", "vagrant",
114];
115
116pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
117    let mut docs = Vec::new();
118    docs.extend(forges::command_docs());
119    docs.extend(node::command_docs());
120    docs.extend(jvm::command_docs());
121    docs.extend(android::command_docs());
122    docs.extend(network::command_docs());
123    docs.extend(system::command_docs());
124    docs.extend(perl::command_docs());
125    docs.extend(coreutils::command_docs());
126    docs.extend(fuzzy::command_docs());
127    docs.extend(shell::command_docs());
128    docs.extend(wrappers::command_docs());
129    docs.extend(crate::registry::toml_command_docs());
130    docs
131}
132
133#[cfg(test)]
134#[derive(Debug)]
135pub(crate) enum CommandEntry {
136    Positional { cmd: &'static str },
137    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
138    Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
139    Delegation { cmd: &'static str },
140}
141
142#[cfg(test)]
143#[derive(Debug)]
144pub(crate) enum SubEntry {
145    Policy { name: &'static str },
146    Nested { name: &'static str, subs: &'static [SubEntry] },
147    Custom { name: &'static str, valid_suffix: Option<&'static str> },
148    Positional,
149    Guarded { name: &'static str, valid_suffix: &'static str },
150}
151
152pub fn all_opencode_patterns() -> Vec<String> {
153    let mut patterns = Vec::new();
154    patterns.sort();
155    patterns.dedup();
156    patterns
157}
158
159#[cfg(test)]
160fn full_registry() -> Vec<&'static CommandEntry> {
161    let mut entries = Vec::new();
162    entries.extend(shell::REGISTRY);
163    entries.extend(wrappers::REGISTRY);
164    entries.extend(forges::full_registry());
165    entries.extend(node::full_registry());
166    entries.extend(jvm::full_registry());
167    entries.extend(android::full_registry());
168    entries.extend(network::REGISTRY);
169    entries.extend(system::full_registry());
170    entries.extend(perl::REGISTRY);
171    entries.extend(coreutils::full_registry());
172    entries.extend(fuzzy::full_registry());
173    entries
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::collections::HashSet;
180
181    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
182    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
183
184    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
185        match entry {
186            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
187            CommandEntry::Custom { cmd, valid_prefix } => {
188                let base = valid_prefix.unwrap_or(cmd);
189                let test = format!("{base} {UNKNOWN_FLAG}");
190                if crate::is_safe_command(&test) {
191                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
192                }
193            }
194            CommandEntry::Subcommand { cmd, subs, bare_ok } => {
195                if !bare_ok && crate::is_safe_command(cmd) {
196                    failures.push(format!("{cmd}: accepted bare invocation"));
197                }
198                let test = format!("{cmd} {UNKNOWN_SUB}");
199                if crate::is_safe_command(&test) {
200                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
201                }
202                for sub in *subs {
203                    check_sub(cmd, sub, failures);
204                }
205            }
206        }
207    }
208
209    fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
210        match entry {
211            SubEntry::Policy { name } => {
212                let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
213                if crate::is_safe_command(&test) {
214                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
215                }
216            }
217            SubEntry::Nested { name, subs } => {
218                let path = format!("{prefix} {name}");
219                let test = format!("{path} {UNKNOWN_SUB}");
220                if crate::is_safe_command(&test) {
221                    failures.push(format!("{path}: accepted unknown subcommand: {test}"));
222                }
223                for sub in *subs {
224                    check_sub(&path, sub, failures);
225                }
226            }
227            SubEntry::Custom { name, valid_suffix } => {
228                let base = match valid_suffix {
229                    Some(s) => format!("{prefix} {name} {s}"),
230                    None => format!("{prefix} {name}"),
231                };
232                let test = format!("{base} {UNKNOWN_FLAG}");
233                if crate::is_safe_command(&test) {
234                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
235                }
236            }
237            SubEntry::Positional => {}
238            SubEntry::Guarded { name, valid_suffix } => {
239                let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
240                if crate::is_safe_command(&test) {
241                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
242                }
243            }
244        }
245    }
246
247    #[test]
248    fn all_commands_reject_unknown() {
249        let registry = full_registry();
250        let mut failures = Vec::new();
251        for entry in &registry {
252            check_entry(entry, &mut failures);
253        }
254        assert!(
255            failures.is_empty(),
256            "unknown flags/subcommands accepted:\n{}",
257            failures.join("\n")
258        );
259    }
260
261    #[test]
262    fn process_substitution_blocked() {
263        let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
264        for cmd in &cmds {
265            assert!(
266                !crate::is_safe_command(cmd),
267                "process substitution not blocked: {cmd}",
268            );
269        }
270    }
271
272    #[test]
273    fn registry_covers_handled_commands() {
274        let registry = full_registry();
275        let mut all_cmds: HashSet<&str> = registry
276            .iter()
277            .map(|e| match e {
278                CommandEntry::Positional { cmd }
279                | CommandEntry::Custom { cmd, .. }
280                | CommandEntry::Subcommand { cmd, .. }
281                | CommandEntry::Delegation { cmd } => *cmd,
282            })
283            .collect();
284        for name in crate::registry::toml_command_names() {
285            all_cmds.insert(name);
286        }
287        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
288
289        let missing: Vec<_> = handled.difference(&all_cmds).collect();
290        assert!(missing.is_empty(), "not in registry: {missing:?}");
291
292        let extra: Vec<_> = all_cmds.difference(&handled).collect();
293        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
294    }
295
296}