openmoat_core/
programs.rs1use std::collections::BTreeMap;
11
12use crate::verdict::{Decision, Verdict};
13
14pub const RULE: &str = "executables";
15
16pub trait ProgramResolver {
18 fn resolve(&self, program: &str) -> Option<String>;
20
21 fn pinned(&self, _program: &str) -> Option<String> {
23 None
24 }
25
26 fn own_binaries(&self) -> Vec<String> {
32 ["openmoat", "moat"]
33 .iter()
34 .filter_map(|name| self.pinned(name))
35 .collect()
36 }
37}
38
39#[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#[derive(Debug, Default, Clone)]
51pub struct MapResolver {
52 pub resolved: BTreeMap<String, String>,
54 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
68pub 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}