Skip to main content

camel_component_exec/
policy.rs

1//! policy.rs — pure security core for the exec component.
2
3use crate::config::{ArgPolicy, DenyFlags, EnvPolicy};
4use crate::error::ExecError;
5use std::collections::HashMap;
6
7/// Validate an args slice against the profile's policy. Per-element. Order:
8/// deny_flags first, then allow mode. Empty slice always passes.
9pub fn eval_args(args: &[String], policy: &ArgPolicy, deny: &DenyFlags) -> Result<(), ExecError> {
10    if args.is_empty() {
11        return Ok(());
12    }
13    for a in args {
14        for f in &deny.0 {
15            if a == f || a.starts_with(f) {
16                return Err(ExecError::ArgPolicyDenied {
17                    arg: a.clone(),
18                    reason: format!("matches deny_flags entry {f:?}"),
19                });
20            }
21        }
22        let ok = match policy {
23            ArgPolicy::Any => true,
24            ArgPolicy::Exact { values } => values.iter().any(|v| v == a),
25            ArgPolicy::Prefix { values } => values.iter().any(|v| a.starts_with(v)),
26        };
27        if !ok {
28            return Err(ExecError::ArgPolicyDenied {
29                arg: a.clone(),
30                reason: "not permitted by allow policy".into(),
31            });
32        }
33    }
34    Ok(())
35}
36
37/// Build the child env: start empty, copy allow-listed host vars, apply `set`,
38/// then strip anything matching `deny_env` globs (deny wins, applied last).
39pub fn build_env(
40    policy: &EnvPolicy,
41    deny_env: &[String],
42    host: &HashMap<String, String>,
43) -> HashMap<String, String> {
44    let mut out = HashMap::new();
45    for k in &policy.allow {
46        if let Some(v) = host.get(k) {
47            out.insert(k.clone(), v.clone());
48        }
49    }
50    for (k, v) in &policy.set {
51        out.insert(k.clone(), v.clone());
52    }
53    // deny_env applied last, always wins.
54    out.retain(|k, _| !deny_env.iter().any(|pat| glob_matches(pat, k)));
55    out
56}
57
58/// Glob matcher backed by `glob::Pattern` (supports `*`, `?`, `[...]`).
59/// Returns false on unparseable patterns (fail-safe: bad pattern matches nothing).
60fn glob_matches(pat: &str, name: &str) -> bool {
61    glob::Pattern::new(pat)
62        .map(|p| p.matches(name))
63        .unwrap_or(false)
64}
65
66/// Defense-in-depth: reject known shells unless `allow_shell`.
67pub fn is_known_shell(exe: &str) -> bool {
68    let base = std::path::Path::new(exe)
69        .file_name()
70        .and_then(|s| s.to_str())
71        .unwrap_or(exe)
72        .to_ascii_lowercase();
73    const SHELLS: &[&str] = &[
74        "sh",
75        "bash",
76        "dash",
77        "zsh",
78        "ksh",
79        "fish",
80        "csh",
81        "tcsh",
82        "cmd.exe",
83        "powershell.exe",
84        "pwsh",
85    ];
86    SHELLS
87        .iter()
88        .any(|s| base == *s || base.starts_with(&format!("{s}.")))
89}
90
91#[cfg(test)]
92mod arg_tests {
93    use super::*;
94    use crate::config::{ArgPolicy, DenyFlags};
95
96    #[test]
97    fn any_accepts_all() {
98        assert!(
99            eval_args(
100                &["log", "--oneline"].map(String::from),
101                &ArgPolicy::Any,
102                &DenyFlags::default()
103            )
104            .is_ok()
105        );
106    }
107    #[test]
108    fn exact_rejects_unknown_element() {
109        let p = ArgPolicy::Exact {
110            values: ["check"].map(String::from).to_vec(),
111        };
112        assert!(eval_args(&["check"].map(String::from), &p, &DenyFlags::default()).is_ok());
113        assert!(
114            eval_args(
115                &["check", "--release"].map(String::from),
116                &p,
117                &DenyFlags::default()
118            )
119            .is_err()
120        );
121    }
122    #[test]
123    fn prefix_matches_per_element() {
124        let p = ArgPolicy::Prefix {
125            values: ["log", "diff", "--"].map(String::from).to_vec(),
126        };
127        assert!(
128            eval_args(
129                &["log", "--oneline"].map(String::from),
130                &p,
131                &DenyFlags::default()
132            )
133            .is_ok()
134        );
135        assert!(eval_args(&["push"].map(String::from), &p, &DenyFlags::default()).is_err());
136    }
137    #[test]
138    fn deny_flags_applied_first() {
139        let p = ArgPolicy::Any;
140        let d = DenyFlags(["--upload-pack"].map(String::from).to_vec());
141        assert!(eval_args(&["--upload-pack=x"].map(String::from), &p, &d).is_err());
142    }
143    #[test]
144    fn empty_args_always_allowed() {
145        let p = ArgPolicy::Exact { values: vec![] };
146        assert!(eval_args(&[], &p, &DenyFlags::default()).is_ok());
147    }
148}
149
150#[cfg(test)]
151mod env_shell_tests {
152    use super::*;
153    use crate::config::EnvPolicy;
154    use std::collections::HashMap;
155
156    fn env(allow: &[&str], set: &[(&str, &str)]) -> EnvPolicy {
157        EnvPolicy {
158            allow: allow.iter().map(|s| s.to_string()).collect(),
159            set: set
160                .iter()
161                .map(|(k, v)| (k.to_string(), v.to_string()))
162                .collect(),
163        }
164    }
165
166    #[test]
167    fn deny_env_globs_stripped_last() {
168        // Host carries a token; allow-list ALSO permits it; set explicitly sets another token.
169        // deny_env *_TOKEN must strip BOTH (copied-from-allow AND explicitly-set),
170        // proving deny is applied last and always wins.
171        let mut host = HashMap::new();
172        host.insert("POLICY_TEST_GITHUB_TOKEN".to_string(), "leak".into());
173        host.insert("POLICY_TEST_HOME".to_string(), "/h".into());
174        let p = env(
175            &["POLICY_TEST_HOME", "POLICY_TEST_GITHUB_TOKEN"], // token allowed (copied in)
176            &[("CI", "1"), ("POLICY_TEST_AWS_TOKEN", "secret")], // AWS_TOKEN set explicitly
177        );
178        let out = build_env(&p, &["*_TOKEN".to_string()], &host);
179        assert_eq!(out.get("CI"), Some(&"1".to_string()));
180        assert_eq!(out.get("POLICY_TEST_HOME"), Some(&"/h".to_string()));
181        assert!(
182            !out.contains_key("POLICY_TEST_GITHUB_TOKEN"),
183            "deny_env must strip allowed *_TOKEN"
184        );
185        assert!(
186            !out.contains_key("POLICY_TEST_AWS_TOKEN"),
187            "deny_env must strip explicitly-set *_TOKEN"
188        );
189    }
190
191    #[test]
192    fn known_shell_rejected_unless_allowed() {
193        assert!(is_known_shell("/bin/sh"));
194        assert!(is_known_shell("/usr/bin/bash"));
195        assert!(is_known_shell("cmd.exe"));
196        assert!(is_known_shell("/usr/bin/pwsh.exe")); // .exe-suffix branch
197        assert!(!is_known_shell("/usr/bin/git"));
198    }
199}