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    "7z", "7zz", "7za", "zstd", "unzstd", "zstdcat", "zstdmt", "xz", "unxz", "xzcat",
150    "lzma", "unlzma", "lzcat", "bzip2", "bunzip2", "bzcat", "bzip2recover", "brotli",
151    "lz4", "unlz4", "lz4cat", "lz4c", "pigz", "unpigz", "pbzip2", "lzip", "lunzip", "plzip", "ar",
152    "openssl", "mkcert", "certbot",
153    "ssh-keygen", "ssh-keyscan", "ssh-copy-id", "ssh-add", "ssh-agent",
154    "scp", "sftp", "sshfs", "autossh", "mosh",
155    "caddy", "nginx", "haproxy", "envoy", "traefik",
156    "hadolint", "dive", "nerdctl", "ctr", "copa",
157    "wasmtime", "wasmer", "wasm-pack", "wat2wasm", "wasm2wat",
158    "wasm-validate", "wasm-objdump", "wasm-strip", "wasm-tools", "spin",
159    "hg", "svn", "fossil", "pijul",
160    "newman", "bru", "inso", "ab", "wrk", "iperf3", "iperf",
161    "vegeta", "k6", "locust", "bombardier", "siege", "artillery",
162    "huggingface-cli", "sentry-cli", "promtool", "grafana-cli", "amtool", "otel-cli", "oc",
163    "arduino-cli", "pio", "platformio", "esptool", "esptool.py",
164    "avrdude", "openocd", "west", "idf.py",
165    "coqc", "coqtop", "lean", "lake", "elan", "agda", "idris2", "swipl",
166    "code", "code-insiders", "codium", "hx", "subl",
167    "earthly", "buck2", "pants", "waf", "xmake",
168    "shfmt", "yamllint", "jsonlint", "editorconfig-checker", "ec",
169    "pkg-config", "envsubst",
170    "ansible-vault", "molecule",
171    "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
172    "cargo", "rustup",
173    "go",
174    "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
175    "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
176    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
177    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
178    "fastlane", "firebase",
179    "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
180    "swift",
181    "dotnet",
182    "curl",
183    "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
184    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
185    "ddev", "dcli",
186    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
187    "pg_dump", "bazel", "meson", "ninja",
188    "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
189    "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
190    "cx", "hey", "wrangler", "cf", "newrelic",
191    "aws", "gcloud", "az",
192    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
193    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
194    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
195    "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
196    "monolith",
197    "cloudflared", "ngrok", "ssh",
198    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
199    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
200    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
201    "perl",
202    "R", "Rscript",
203    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
204    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
205    "diff", "comm", "paste", "tac", "rev", "nl",
206    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
207    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
208    "arch", "command", "hostname",
209    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
210    "magick", "convert", "frames",
211    "fd", "eza", "exa", "ls", "delta", "colordiff",
212    "dirname", "basename", "realpath", "readlink",
213    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
214    "true", "false", ":", "shopt",
215    "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
216    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
217    "who", "w", "last", "lastlog",
218    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
219    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
220    "base64", "xxd", "getconf", "uuidgen",
221    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
222    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
223    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
224    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
225    "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
226    "xv",
227    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
228    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
229    "tldr", "ldd", "objdump", "readelf", "just",
230    "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
231    "rubocop", "eslint", "biome", "stylelint", "zoxide",
232    "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
233    "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
234    "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
235    "@astrojs/check", "@changesets/cli",
236    "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
237    "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
238    "@microsoft/api-extractor", "@asyncapi/cli",
239    "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
240    "depcheck", "madge", "license-checker",
241    "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
242    "direnv", "make", "packer", "vagrant",
243    "node", "python3", "python", "rustc", "java", "php",
244    "gcc", "g++", "cc", "c++", "clang", "clang++",
245    "elixir", "erl", "mix", "zig", "lua", "tsc",
246    "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
247    "git-cliff", "git-lfs", "tig",
248    "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
249];
250
251pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
252    let mut docs = Vec::new();
253    docs.extend(forges::command_docs());
254    docs.extend(node::command_docs());
255    docs.extend(jvm::command_docs());
256    docs.extend(android::command_docs());
257    docs.extend(network::command_docs());
258    docs.extend(system::command_docs());
259    docs.extend(perl::command_docs());
260    docs.extend(coreutils::command_docs());
261    docs.extend(fuzzy::command_docs());
262    docs.extend(shell::command_docs());
263    docs.extend(wrappers::command_docs());
264    docs.extend(vcs::command_docs());
265    docs.extend(crate::registry::toml_command_docs());
266    docs
267}
268
269#[cfg(test)]
270#[derive(Debug)]
271pub(crate) enum CommandEntry {
272    Positional { cmd: &'static str },
273    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
274    Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
275    Delegation { cmd: &'static str },
276}
277
278pub fn all_opencode_patterns() -> Vec<String> {
279    let mut patterns = Vec::new();
280    patterns.sort();
281    patterns.dedup();
282    patterns
283}
284
285#[cfg(test)]
286fn full_registry() -> Vec<&'static CommandEntry> {
287    let mut entries = Vec::new();
288    entries.extend(shell::REGISTRY);
289    entries.extend(wrappers::REGISTRY);
290    entries.extend(forges::full_registry());
291    entries.extend(node::full_registry());
292    entries.extend(jvm::full_registry());
293    entries.extend(android::full_registry());
294    entries.extend(network::REGISTRY);
295    entries.extend(system::full_registry());
296    entries.extend(perl::REGISTRY);
297    entries.extend(coreutils::full_registry());
298    entries.extend(fuzzy::full_registry());
299    entries
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use std::collections::HashSet;
306
307    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
308    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
309
310    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
311        match entry {
312            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
313            CommandEntry::Custom { cmd, valid_prefix } => {
314                let base = valid_prefix.unwrap_or(cmd);
315                let test = format!("{base} {UNKNOWN_FLAG}");
316                if crate::is_safe_command(&test) {
317                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
318                }
319            }
320            CommandEntry::Paths { cmd, bare_ok, paths } => {
321                if !bare_ok && crate::is_safe_command(cmd) {
322                    failures.push(format!("{cmd}: accepted bare invocation"));
323                }
324                let test = format!("{cmd} {UNKNOWN_SUB}");
325                if crate::is_safe_command(&test) {
326                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
327                }
328                for path in *paths {
329                    let test = format!("{path} {UNKNOWN_FLAG}");
330                    if crate::is_safe_command(&test) {
331                        failures.push(format!("{path}: accepted unknown flag: {test}"));
332                    }
333                }
334            }
335        }
336    }
337
338    #[test]
339    fn all_commands_reject_unknown() {
340        let registry = full_registry();
341        let mut failures = Vec::new();
342        for entry in &registry {
343            check_entry(entry, &mut failures);
344        }
345        assert!(
346            failures.is_empty(),
347            "unknown flags/subcommands accepted:\n{}",
348            failures.join("\n")
349        );
350    }
351
352    #[test]
353    fn process_substitution_safe_inner() {
354        let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
355        for cmd in &safe {
356            assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
357        }
358    }
359
360    #[test]
361    fn process_substitution_unsafe_inner() {
362        let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
363        for cmd in &unsafe_cmds {
364            assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
365        }
366    }
367
368    #[test]
369    fn registry_covers_handled_commands() {
370        let registry = full_registry();
371        let mut all_cmds: HashSet<&str> = registry
372            .iter()
373            .map(|e| match e {
374                CommandEntry::Positional { cmd }
375                | CommandEntry::Custom { cmd, .. }
376                | CommandEntry::Paths { cmd, .. }
377                | CommandEntry::Delegation { cmd } => *cmd,
378            })
379            .collect();
380        for name in crate::registry::toml_command_names() {
381            all_cmds.insert(name);
382        }
383        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
384
385        let missing: Vec<_> = handled.difference(&all_cmds).collect();
386        assert!(missing.is_empty(), "not in registry: {missing:?}");
387
388        let extra: Vec<_> = all_cmds.difference(&handled).collect();
389        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
390    }
391
392}