Skip to main content

safe_chains/handlers/
mod.rs

1macro_rules! handler_module {
2    ($($sub:ident),+ $(,)?) => {
3        $(mod $sub;)+
4
5        pub(crate) fn dispatch(cmd: &str, tokens: &[crate::parse::Token]) -> Option<crate::verdict::Verdict> {
6            None$(.or_else(|| $sub::dispatch(cmd, tokens)))+
7        }
8
9        pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
10            let mut docs = Vec::new();
11            $(docs.extend($sub::command_docs());)+
12            docs
13        }
14
15        #[cfg(test)]
16        pub(super) fn full_registry() -> Vec<&'static super::CommandEntry> {
17            let mut v = Vec::new();
18            $(v.extend($sub::REGISTRY);)+
19            v
20        }
21    };
22}
23
24pub mod android;
25pub mod coreutils;
26pub mod forges;
27pub mod fuzzy;
28pub mod jvm;
29pub mod magick;
30pub mod network;
31pub mod node;
32pub mod perl;
33pub mod php;
34pub mod ruby;
35pub mod shell;
36pub mod system;
37pub mod vcs;
38pub mod wrappers;
39
40use std::collections::HashMap;
41
42use crate::parse::Token;
43use crate::verdict::Verdict;
44
45type HandlerFn = fn(&[Token]) -> Verdict;
46
47pub fn custom_cmd_handlers() -> HashMap<&'static str, HandlerFn> {
48    HashMap::from([
49        ("magick", magick::is_safe_magick as HandlerFn),
50        ("php", php::is_safe_php as HandlerFn),
51        ("sysctl", system::sysctl::is_safe_sysctl as HandlerFn),
52    ])
53}
54
55pub fn custom_sub_handlers() -> HashMap<&'static str, HandlerFn> {
56    HashMap::from([
57        ("bun_x", node::bun::check_bun_x as HandlerFn),
58        ("bundle_config", ruby::bundle::check_bundle_config as HandlerFn),
59        ("bundle_exec", ruby::bundle::check_bundle_exec as HandlerFn),
60        ("git_remote", vcs::git::check_git_remote as HandlerFn),
61        ("laravel_cache_clear", php::check_laravel_cache_clear as HandlerFn),
62        ("plutil_convert", system::plutil::check_plutil_convert as HandlerFn),
63    ])
64}
65
66pub fn dispatch(tokens: &[Token]) -> Verdict {
67    let cmd = tokens[0].command_name();
68    None
69        .or_else(|| shell::dispatch(cmd, tokens))
70        .or_else(|| wrappers::dispatch(cmd, tokens))
71        .or_else(|| forges::dispatch(cmd, tokens))
72        .or_else(|| node::dispatch(cmd, tokens))
73        .or_else(|| jvm::dispatch(cmd, tokens))
74        .or_else(|| android::dispatch(cmd, tokens))
75        .or_else(|| network::dispatch(cmd, tokens))
76        .or_else(|| system::dispatch(cmd, tokens))
77        .or_else(|| perl::dispatch(cmd, tokens))
78        .or_else(|| coreutils::dispatch(cmd, tokens))
79        .or_else(|| fuzzy::dispatch(cmd, tokens))
80        .or_else(|| vcs::dispatch(cmd, tokens))
81        .or_else(|| crate::registry::toml_dispatch(tokens))
82        .unwrap_or(Verdict::Denied)
83}
84
85#[cfg(test)]
86const HANDLED_CMDS: &[&str] = &[
87    "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv", "jai",
88    "git", "jj", "gh", "glab", "jjpr", "tea", "basecamp",
89    "jira", "linear", "notion", "td", "todoist", "trello",
90    "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta", "mocha",
91    "ruby", "ri", "bundle", "gem", "importmap", "rails", "rbenv", "rvm", "brakeman", "rspec",
92    "standardrb", "erb_lint", "erblint", "herb",
93    "reek", "flay", "flog", "fasterer", "haml-lint", "slim-lint",
94    "bundler-audit", "bundle-audit", "ruby-audit", "rdoc", "yard", "yardoc", "rubycritic",
95    "annotaterb", "annotate", "jekyll", "bridgetown", "middleman", "foreman", "guard",
96    "spring", "overcommit", "pry", "byebug", "thor", "m", "rake", "sdoc", "license_finder",
97    "danger", "kamal", "mutant", "whenever", "haml", "slimrb", "railroady", "erd",
98    "parallel_test", "parallel_rspec", "parallel_cucumber", "parallel_spinach",
99    "racc", "rex",
100    "steep", "srb", "rbs", "typeprof", "stree", "rufo", "packwerk", "debride",
101    "i18n-tasks", "asciidoctor", "kramdown", "dawn", "fpm", "stackprof",
102    "pipx", "pip-compile", "pip-sync", "pre-commit", "sphinx-build", "sphinx-quickstart", "sphinx-apidoc",
103    "mkdocs", "twine", "yapf", "autopep8", "autoflake", "pyupgrade", "vulture", "pyflakes",
104    "pycodestyle", "pydocstyle", "cookiecutter", "copier", "deptry", "safety",
105    "http", "https", "ipython", "scalene", "py-spy", "kernprof", "mprof", "dvc", "alembic", "hatch",
106    "husky", "lint-staged", "markdownlint-cli2", "markdownlint", "typedoc", "nodemon", "pm2",
107    "ncu", "depcruise", "dependency-cruise",
108    "rollup", "vite", "esbuild", "swc", "webpack", "parcel", "tsup",
109    "prisma", "drizzle-kit", "sequelize", "knex",
110    "ava", "tap", "c8", "nyc", "jasmine",
111    "http-server", "serve", "concurrently",
112    "npm-run-all", "run-p", "run-s",
113    "tsx", "ts-node", "cucumber-js", "@cucumber/cucumber",
114    "bacon", "sccache", "sqlx", "diesel", "starship", "atuin",
115    "gofmt", "goimports", "gofumpt", "gci", "revive", "errcheck",
116    "gotestsum", "goreleaser", "mage", "task", "buf", "gosec", "gomodifytags", "dlv",
117    "scala", "scalac", "sbt", "mill", "groovy", "lein", "clj", "clojure",
118    "kotlinc", "scalafmt", "scalafix", "jdeps", "jcmd", "jstack",
119    "ghc", "cabal", "stack", "hlint", "ormolu", "fourmolu",
120    "opam", "dune", "ocamlformat",
121    "credo", "iex",
122    "clang-format", "clang-tidy", "cppcheck", "doxygen", "autoconf", "automake", "cmake-format",
123    "crystal", "shards", "ameba",
124    "nim", "nimble",
125    "luarocks", "selene",
126    "julia",
127    "dart", "flutter",
128    "hugo", "zola", "eleventy", "@11ty/eleventy", "gatsby", "astro", "vitepress", "hexo",
129    "op", "bw", "pass", "vault", "gpg", "age", "sops",
130    "terraform-docs", "tflint", "tfsec", "terragrunt", "ansible-lint", "helmfile",
131    "argocd", "skaffold", "tilt", "consul", "nomad",
132    "jupyter", "jupytext", "nbqa", "jupyter-nbconvert", "nbconvert", "nbstripout",
133    "mlflow", "wandb", "papermill", "dbt",
134    "rebar3", "fantomas", "cpan", "cpanm", "plenv", "carton",
135    "latexmk", "pdflatex", "xelatex", "lualatex", "latex", "biber",
136    "dub", "sbcl", "ros", "raco", "gleam", "roc",
137    "ffmpeg", "ffprobe", "exiftool", "mediainfo",
138    "jpegoptim", "optipng", "pngquant", "gifsicle", "cwebp", "sox",
139    "pandoc", "marp",
140    "rsync", "rclone", "restic", "borg", "mc",
141    "mysql", "mariadb", "mongosh", "redis-cli", "sqlite3", "duckdb", "usql", "pg_restore",
142    "entr", "parallel", "ts", "sponge", "vipe", "vidir", "chronic", "ifne", "errno", "isutf8", "pee",
143    "buildah", "velero", "flux", "linkerd", "istioctl", "kapp", "ytt",
144    "nu", "pscale", "supabase", "neon", "neonctl",
145    "nuget", "paket", "msbuild", "cake", "nuke", "docfx",
146    "mono", "mcs", "fsi", "dotnet-fsi", "csi",
147    "dotnet-ef", "dotnet-script", "dotnet-counters", "dotnet-dump",
148    "dotnet-trace", "dotnet-stack", "dotnet-gcdump", "dotnet-sos", "dotnet-symbol",
149    "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
150    "cargo", "rustup",
151    "go",
152    "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
153    "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
154    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
155    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
156    "fastlane", "firebase",
157    "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
158    "swift",
159    "dotnet",
160    "curl",
161    "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
162    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
163    "ddev", "dcli",
164    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
165    "pg_dump", "bazel", "meson", "ninja",
166    "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
167    "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
168    "cx", "hey", "wrangler", "cf", "newrelic",
169    "aws", "gcloud", "az",
170    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
171    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
172    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
173    "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
174    "monolith",
175    "cloudflared", "ngrok", "ssh",
176    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
177    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
178    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
179    "perl",
180    "R", "Rscript",
181    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
182    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
183    "diff", "comm", "paste", "tac", "rev", "nl",
184    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
185    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
186    "arch", "command", "hostname",
187    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
188    "magick", "convert", "frames",
189    "fd", "eza", "exa", "ls", "delta", "colordiff",
190    "dirname", "basename", "realpath", "readlink",
191    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
192    "true", "false", ":", "shopt",
193    "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
194    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
195    "who", "w", "last", "lastlog",
196    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
197    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
198    "base64", "xxd", "getconf", "uuidgen",
199    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
200    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
201    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
202    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
203    "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
204    "xv",
205    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
206    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
207    "tldr", "ldd", "objdump", "readelf", "just",
208    "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
209    "rubocop", "eslint", "biome", "stylelint", "zoxide",
210    "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
211    "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
212    "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
213    "@astrojs/check", "@changesets/cli",
214    "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
215    "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
216    "@microsoft/api-extractor", "@asyncapi/cli",
217    "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
218    "depcheck", "madge", "license-checker",
219    "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
220    "direnv", "make", "packer", "vagrant",
221    "node", "python3", "python", "rustc", "java", "php",
222    "gcc", "g++", "cc", "c++", "clang", "clang++",
223    "elixir", "erl", "mix", "zig", "lua", "tsc",
224    "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
225    "git-cliff", "git-lfs", "tig",
226    "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
227];
228
229pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
230    let mut docs = Vec::new();
231    docs.extend(forges::command_docs());
232    docs.extend(node::command_docs());
233    docs.extend(jvm::command_docs());
234    docs.extend(android::command_docs());
235    docs.extend(network::command_docs());
236    docs.extend(system::command_docs());
237    docs.extend(perl::command_docs());
238    docs.extend(coreutils::command_docs());
239    docs.extend(fuzzy::command_docs());
240    docs.extend(shell::command_docs());
241    docs.extend(wrappers::command_docs());
242    docs.extend(vcs::command_docs());
243    docs.extend(crate::registry::toml_command_docs());
244    docs
245}
246
247#[cfg(test)]
248#[derive(Debug)]
249pub(crate) enum CommandEntry {
250    Positional { cmd: &'static str },
251    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
252    Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
253    Delegation { cmd: &'static str },
254}
255
256pub fn all_opencode_patterns() -> Vec<String> {
257    let mut patterns = Vec::new();
258    patterns.sort();
259    patterns.dedup();
260    patterns
261}
262
263#[cfg(test)]
264fn full_registry() -> Vec<&'static CommandEntry> {
265    let mut entries = Vec::new();
266    entries.extend(shell::REGISTRY);
267    entries.extend(wrappers::REGISTRY);
268    entries.extend(forges::full_registry());
269    entries.extend(node::full_registry());
270    entries.extend(jvm::full_registry());
271    entries.extend(android::full_registry());
272    entries.extend(network::REGISTRY);
273    entries.extend(system::full_registry());
274    entries.extend(perl::REGISTRY);
275    entries.extend(coreutils::full_registry());
276    entries.extend(fuzzy::full_registry());
277    entries
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::collections::HashSet;
284
285    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
286    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
287
288    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
289        match entry {
290            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
291            CommandEntry::Custom { cmd, valid_prefix } => {
292                let base = valid_prefix.unwrap_or(cmd);
293                let test = format!("{base} {UNKNOWN_FLAG}");
294                if crate::is_safe_command(&test) {
295                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
296                }
297            }
298            CommandEntry::Paths { cmd, bare_ok, paths } => {
299                if !bare_ok && crate::is_safe_command(cmd) {
300                    failures.push(format!("{cmd}: accepted bare invocation"));
301                }
302                let test = format!("{cmd} {UNKNOWN_SUB}");
303                if crate::is_safe_command(&test) {
304                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
305                }
306                for path in *paths {
307                    let test = format!("{path} {UNKNOWN_FLAG}");
308                    if crate::is_safe_command(&test) {
309                        failures.push(format!("{path}: accepted unknown flag: {test}"));
310                    }
311                }
312            }
313        }
314    }
315
316    #[test]
317    fn all_commands_reject_unknown() {
318        let registry = full_registry();
319        let mut failures = Vec::new();
320        for entry in &registry {
321            check_entry(entry, &mut failures);
322        }
323        assert!(
324            failures.is_empty(),
325            "unknown flags/subcommands accepted:\n{}",
326            failures.join("\n")
327        );
328    }
329
330    #[test]
331    fn process_substitution_safe_inner() {
332        let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
333        for cmd in &safe {
334            assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
335        }
336    }
337
338    #[test]
339    fn process_substitution_unsafe_inner() {
340        let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
341        for cmd in &unsafe_cmds {
342            assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
343        }
344    }
345
346    #[test]
347    fn registry_covers_handled_commands() {
348        let registry = full_registry();
349        let mut all_cmds: HashSet<&str> = registry
350            .iter()
351            .map(|e| match e {
352                CommandEntry::Positional { cmd }
353                | CommandEntry::Custom { cmd, .. }
354                | CommandEntry::Paths { cmd, .. }
355                | CommandEntry::Delegation { cmd } => *cmd,
356            })
357            .collect();
358        for name in crate::registry::toml_command_names() {
359            all_cmds.insert(name);
360        }
361        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
362
363        let missing: Vec<_> = handled.difference(&all_cmds).collect();
364        assert!(missing.is_empty(), "not in registry: {missing:?}");
365
366        let extra: Vec<_> = all_cmds.difference(&handled).collect();
367        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
368    }
369
370}