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
27/// Resolver for callers without a search path (tests, `policy check` without state).
28#[derive(Debug, Default, Clone, Copy)]
29pub struct NoResolver;
30
31impl ProgramResolver for NoResolver {
32    fn resolve(&self, _program: &str) -> Option<String> {
33        None
34    }
35}
36
37/// In-memory resolver for tests and conformance fixtures.
38#[derive(Debug, Default, Clone)]
39pub struct MapResolver {
40    /// Program name → path it resolves to on the search path.
41    pub resolved: BTreeMap<String, String>,
42    /// Program name → path recorded at install time.
43    pub pins: BTreeMap<String, String>,
44}
45
46impl ProgramResolver for MapResolver {
47    fn resolve(&self, program: &str) -> Option<String> {
48        self.resolved.get(program).cloned()
49    }
50
51    fn pinned(&self, program: &str) -> Option<String> {
52        self.pins.get(program).cloned()
53    }
54}
55
56/// Check one command's program against policy pins and installation pins.
57///
58/// `argv0` is the command as written; `policy_pins` is the policy's
59/// `executables` table. Returns a deny decision on mismatch, `None` when the
60/// program is unpinned or runs from an expected path.
61pub fn check(
62    argv0: &str,
63    policy_pins: &BTreeMap<String, Vec<String>>,
64    resolver: &dyn ProgramResolver,
65) -> Option<Decision> {
66    let name = basename(argv0);
67    let mut expected: Vec<String> = policy_pins.get(name).cloned().unwrap_or_default();
68    if let Some(installed) = resolver.pinned(name)
69        && !expected.contains(&installed)
70    {
71        expected.push(installed);
72    }
73    if expected.is_empty() {
74        return None;
75    }
76    let actual = if argv0.contains('/') || argv0.contains('\\') {
77        Some(argv0.replace('\\', "/"))
78    } else {
79        resolver.resolve(name)
80    };
81    let reason = match actual {
82        Some(path) if expected.iter().any(|e| e == &path) => return None,
83        Some(path) => format!(
84            "`{name}` resolves to {path}, not a pinned path ({})",
85            expected.join(", ")
86        ),
87        None => format!(
88            "`{name}` is pinned ({}) but was not found on the kernel search path",
89            expected.join(", ")
90        ),
91    };
92    Some(Decision::single(Verdict::Deny, RULE, reason))
93}
94
95fn basename(program: &str) -> &str {
96    program.rsplit(['/', '\\']).next().unwrap_or(program)
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn pins(name: &str, path: &str) -> BTreeMap<String, Vec<String>> {
104        BTreeMap::from([(name.to_owned(), vec![path.to_owned()])])
105    }
106
107    fn resolver(name: &str, path: &str) -> MapResolver {
108        MapResolver {
109            resolved: BTreeMap::from([(name.to_owned(), path.to_owned())]),
110            pins: BTreeMap::new(),
111        }
112    }
113
114    #[test]
115    fn unpinned_programs_are_not_checked() {
116        assert!(check("git", &BTreeMap::new(), &NoResolver).is_none());
117        assert!(check("git", &BTreeMap::new(), &resolver("git", "/tmp/evil/git")).is_none());
118    }
119
120    #[test]
121    fn pinned_program_from_expected_path_passes() {
122        let p = pins("git", "/usr/bin/git");
123        assert!(check("git", &p, &resolver("git", "/usr/bin/git")).is_none());
124        assert!(check("/usr/bin/git", &p, &NoResolver).is_none());
125    }
126
127    #[test]
128    fn planted_or_missing_binary_is_denied() {
129        let p = pins("git", "/usr/bin/git");
130        let d = check("git", &p, &resolver("git", "/p/node_modules/.bin/git")).unwrap();
131        assert_eq!(d.verdict, Verdict::Deny);
132        assert_eq!(d.rules, [RULE]);
133        assert!(d.reasons[0].contains("/p/node_modules/.bin/git"));
134        let d = check("/tmp/x/git", &p, &NoResolver).unwrap();
135        assert!(d.reasons[0].contains("/tmp/x/git"));
136        let d = check("git", &p, &NoResolver).unwrap();
137        assert!(d.reasons[0].contains("not found"));
138    }
139
140    #[test]
141    fn installation_pins_count_without_policy_pins() {
142        let r = MapResolver {
143            resolved: BTreeMap::from([("npm".to_owned(), "/tmp/npm".to_owned())]),
144            pins: BTreeMap::from([("npm".to_owned(), "/opt/homebrew/bin/npm".to_owned())]),
145        };
146        assert!(check("npm", &BTreeMap::new(), &r).is_some());
147        let ok = MapResolver {
148            resolved: BTreeMap::from([("npm".to_owned(), "/opt/homebrew/bin/npm".to_owned())]),
149            ..r
150        };
151        assert!(check("npm", &BTreeMap::new(), &ok).is_none());
152    }
153}