Skip to main content

openmoat_core/
programs.rs

1//! Executable pinning: which binary a command name really runs.
2//!
3//! A policy can pin program names to absolute paths (`executables:`), and the
4//! kernel records where common programs lived when it was installed. At decision
5//! time the first word of every shell command is resolved through a resolver the
6//! caller supplies (the core itself never touches the filesystem) and compared
7//! with those pins. A `git` that now resolves somewhere else is denied before it
8//! runs, which defeats planted binaries and search-path poisoning.
9
10use std::collections::BTreeMap;
11
12use crate::verdict::{Decision, Verdict};
13
14pub const RULE: &str = "executables";
15
16/// Resolves program names using a search path the kernel controls.
17pub trait ProgramResolver {
18    /// Absolute path `program` would run from, or `None` if it is not found.
19    fn resolve(&self, program: &str) -> Option<String>;
20
21    /// Path recorded for `program` when the kernel was installed, if any.
22    fn pinned(&self, _program: &str) -> Option<String> {
23        None
24    }
25
26    /// Where OpenMoat's own binary and its alias are installed. A command
27    /// whose program word reaches one of these files, through a link under
28    /// another name or on the search path, is decided as `openmoat` too, so
29    /// the rules that keep OpenMoat's person-only commands from an agent see
30    /// it. By default the pins of `openmoat` and `moat`.
31    fn own_binaries(&self) -> Vec<String> {
32        ["openmoat", "moat"]
33            .iter()
34            .filter_map(|name| self.pinned(name))
35            .collect()
36    }
37}
38
39/// Resolver for callers without a search path (tests, `policy check` without state).
40#[derive(Debug, Default, Clone, Copy)]
41pub struct NoResolver;
42
43impl ProgramResolver for NoResolver {
44    fn resolve(&self, _program: &str) -> Option<String> {
45        None
46    }
47}
48
49/// In-memory resolver for tests and conformance fixtures.
50#[derive(Debug, Default, Clone)]
51pub struct MapResolver {
52    /// Program name → path it resolves to on the search path.
53    pub resolved: BTreeMap<String, String>,
54    /// Program name → path recorded at install time.
55    pub pins: BTreeMap<String, String>,
56}
57
58impl ProgramResolver for MapResolver {
59    fn resolve(&self, program: &str) -> Option<String> {
60        self.resolved.get(program).cloned()
61    }
62
63    fn pinned(&self, program: &str) -> Option<String> {
64        self.pins.get(program).cloned()
65    }
66}
67
68/// Check one command's program against policy pins and installation pins.
69///
70/// `argv0` is the command as written; `policy_pins` is the policy's
71/// `executables` table. Returns a deny decision on mismatch, `None` when the
72/// program is unpinned or runs from an expected path.
73pub fn check(
74    argv0: &str,
75    policy_pins: &BTreeMap<String, Vec<String>>,
76    resolver: &dyn ProgramResolver,
77) -> Option<Decision> {
78    let name = basename(argv0);
79    let mut expected: Vec<String> = policy_pins.get(name).cloned().unwrap_or_default();
80    if let Some(installed) = resolver.pinned(name)
81        && !expected.contains(&installed)
82    {
83        expected.push(installed);
84    }
85    if expected.is_empty() {
86        return None;
87    }
88    let actual = if argv0.contains('/') || argv0.contains('\\') {
89        Some(argv0.replace('\\', "/"))
90    } else {
91        resolver.resolve(name)
92    };
93    let reason = match actual {
94        Some(path) if expected.iter().any(|e| e == &path) => return None,
95        Some(path) => format!(
96            "`{name}` resolves to {path}, not a pinned path ({})",
97            expected.join(", ")
98        ),
99        None => format!(
100            "`{name}` is pinned ({}) but was not found on the kernel search path",
101            expected.join(", ")
102        ),
103    };
104    Some(Decision::single(Verdict::Deny, RULE, reason))
105}
106
107fn basename(program: &str) -> &str {
108    program.rsplit(['/', '\\']).next().unwrap_or(program)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn pins(name: &str, path: &str) -> BTreeMap<String, Vec<String>> {
116        BTreeMap::from([(name.to_owned(), vec![path.to_owned()])])
117    }
118
119    fn resolver(name: &str, path: &str) -> MapResolver {
120        MapResolver {
121            resolved: BTreeMap::from([(name.to_owned(), path.to_owned())]),
122            pins: BTreeMap::new(),
123        }
124    }
125
126    #[test]
127    fn unpinned_programs_are_not_checked() {
128        assert!(check("git", &BTreeMap::new(), &NoResolver).is_none());
129        assert!(check("git", &BTreeMap::new(), &resolver("git", "/tmp/evil/git")).is_none());
130    }
131
132    #[test]
133    fn pinned_program_from_expected_path_passes() {
134        let p = pins("git", "/usr/bin/git");
135        assert!(check("git", &p, &resolver("git", "/usr/bin/git")).is_none());
136        assert!(check("/usr/bin/git", &p, &NoResolver).is_none());
137    }
138
139    #[test]
140    fn planted_or_missing_binary_is_denied() {
141        let p = pins("git", "/usr/bin/git");
142        let d = check("git", &p, &resolver("git", "/p/node_modules/.bin/git")).unwrap();
143        assert_eq!(d.verdict, Verdict::Deny);
144        assert_eq!(d.rules, [RULE]);
145        assert!(d.reasons[0].contains("/p/node_modules/.bin/git"));
146        let d = check("/tmp/x/git", &p, &NoResolver).unwrap();
147        assert!(d.reasons[0].contains("/tmp/x/git"));
148        let d = check("git", &p, &NoResolver).unwrap();
149        assert!(d.reasons[0].contains("not found"));
150    }
151
152    #[test]
153    fn installation_pins_count_without_policy_pins() {
154        let r = MapResolver {
155            resolved: BTreeMap::from([("npm".to_owned(), "/tmp/npm".to_owned())]),
156            pins: BTreeMap::from([("npm".to_owned(), "/opt/homebrew/bin/npm".to_owned())]),
157        };
158        assert!(check("npm", &BTreeMap::new(), &r).is_some());
159        let ok = MapResolver {
160            resolved: BTreeMap::from([("npm".to_owned(), "/opt/homebrew/bin/npm".to_owned())]),
161            ..r
162        };
163        assert!(check("npm", &BTreeMap::new(), &ok).is_none());
164    }
165}