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
27#[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#[derive(Debug, Default, Clone)]
39pub struct MapResolver {
40 pub resolved: BTreeMap<String, String>,
42 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
56pub 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}