Skip to main content

openmoat_core/
lint.rs

1//! Policy warnings: valid policies that probably do not mean what they say.
2//!
3//! Errors (`Policy::lint`) make a policy unusable; warnings do not. Evaluation
4//! is `deny → allow → ask` per atomic action, so a pattern in a later list that
5//! an earlier list already matches can never decide anything. The checks are
6//! conservative: a warning means the shadowing was shown from the patterns
7//! alone; silence does not prove a rule is reachable.
8
9use std::fmt;
10
11use crate::kind::Kind;
12use crate::paths;
13use crate::pattern::{self, GlobPattern, ShellPattern};
14use crate::policy::{Defaults, Policy, RuleGroup};
15use crate::secret::Source as SecretSource;
16
17/// Placeholders so `~` and `${project}` compare equal on both sides without
18/// `{…}` being read as glob alternation.
19const HOME: &str = "/__home__";
20const PROJECT: &str = "/__project__";
21
22/// A policy construct that is valid but probably not what its author meant.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Warning {
25    /// Rule id the warning is about (`defaults` for the defaults table).
26    pub rule: String,
27    /// What is wrong and what happens instead.
28    pub message: String,
29}
30
31impl fmt::Display for Warning {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        write!(f, "rule `{}`: {}", self.rule, self.message)
34    }
35}
36
37/// Every warning for `policy`, in policy order. `policy` must already lint.
38#[must_use]
39pub fn warnings(policy: &Policy) -> Vec<Warning> {
40    let mut out = unknown_default_kinds(&policy.defaults);
41    for (key, present) in [
42        ("approval", policy.approval.is_some()),
43        ("scope", policy.scope.is_some()),
44    ] {
45        if present {
46            out.push(Warning {
47                rule: key.to_owned(),
48                message: format!(
49                    "`{key}` is reserved and has no effect yet; approvals are made with `moat allow` \
50                     and the project root is the git root of the call's working directory"
51                ),
52            });
53        }
54    }
55    shadowed(&policy.ask, &policy.allow, "ask", "allow", &mut out);
56    shadowed(&policy.allow, &policy.deny, "allow", "deny", &mut out);
57    for group in policy.deny.iter().chain(&policy.allow).chain(&policy.ask) {
58        excluded_positives(group, &mut out);
59        fetch_exclusions_net_matches(group, &mut out);
60    }
61    secrets(policy, &mut out);
62    out
63}
64
65/// A secret whose host the proxy refuses is never injected, and one whose
66/// source the agent may read is not kept from the agent.
67fn secrets(policy: &Policy, out: &mut Vec<Warning>) {
68    for secret in &policy.secrets {
69        let mut warn = |message: String| {
70            out.push(Warning {
71                rule: format!("secrets.{}", secret.id),
72                message,
73            });
74        };
75        if !listed(&policy.allow, &[Kind::Net, Kind::Fetch], &secret.host) {
76            warn(format!(
77                "no net or fetch allow rule names {}, so `moat proxy` refuses it and the \
78                 secret is never injected",
79                secret.host
80            ));
81        }
82        let exposed = match &secret.source {
83            SecretSource::File(path) => paths::expand_pattern(path, HOME, Some(PROJECT))
84                .filter(|p| !listed(&policy.deny, &[Kind::FsRead], p))
85                .map(|_| format!("no deny fs.read rule covers `{path}`, so the agent can read it")),
86            SecretSource::Env(name) => (!listed(&policy.deny, &[Kind::EnvRead], name)).then(|| {
87                format!("no deny env.read rule covers `{name}`, so the agent can read it")
88            }),
89            SecretSource::Keychain { .. } => None,
90        };
91        if let Some(message) = exposed {
92            warn(message);
93        }
94        if secret.plain_http && !secret.is_loopback() {
95            warn(format!(
96                "`plain_http: true` sends the value to {} in clear text, readable by anyone \
97                 on the network path",
98                secret.host
99            ));
100        }
101    }
102}
103
104/// Does a group in `groups` match `candidate` in one of `kinds`, `!` exclusions
105/// applied?
106fn listed(groups: &[RuleGroup], kinds: &[Kind], candidate: &str) -> bool {
107    groups.iter().any(|g| {
108        kinds.iter().any(|k| {
109            let list: Vec<GlobPattern> = g.patterns(*k).iter().filter_map(|p| glob(p)).collect();
110            pattern::any_match(&list, candidate)
111        })
112    })
113}
114
115fn unknown_default_kinds(defaults: &Defaults) -> Vec<Warning> {
116    let Defaults::PerKind(map) = defaults else {
117        return Vec::new();
118    };
119    let known: Vec<&str> = std::iter::once("*")
120        .chain(Kind::ALL.iter().map(|k| k.as_str()))
121        .collect();
122    map.keys()
123        .filter(|k| !known.contains(&k.as_str()))
124        .map(|k| Warning {
125            rule: "defaults".to_owned(),
126            message: format!(
127                "unknown kind `{k}` is ignored; known kinds: {}",
128                known.join(", ")
129            ),
130        })
131        .collect()
132}
133
134/// Warn for every pattern in `later` that a pattern in `earlier` (evaluated
135/// first) fully covers: one of the same kind, or a `net` pattern for a `fetch`
136/// one (`net` lists match fetches too; a `fetch` pattern never covers a `net`
137/// one). Lists with `!` exclusions are never assumed to cover anything.
138fn shadowed(
139    later: &[RuleGroup],
140    earlier: &[RuleGroup],
141    later_name: &str,
142    earlier_name: &str,
143    out: &mut Vec<Warning>,
144) {
145    for group in later {
146        for kind in Kind::ALL {
147            for pattern in group.patterns(kind) {
148                let hit = earlier.iter().find_map(|e| {
149                    kind.rule_kinds().iter().find_map(|by_kind| {
150                        let list = e.patterns(*by_kind);
151                        if list.iter().any(|p| p.starts_with('!')) {
152                            return None;
153                        }
154                        list.iter()
155                            .find(|by| covers(kind, by, pattern))
156                            .map(|by| (&e.id, *by_kind, by))
157                    })
158                });
159                if let Some((by_rule, by_kind, by_pattern)) = hit {
160                    out.push(Warning {
161                        rule: group.id.clone(),
162                        message: format!(
163                            "{later_name} {kind} pattern `{pattern}` is unreachable: \
164                             {earlier_name} rule `{by_rule}` {by_kind} pattern `{by_pattern}` \
165                             matches everything it matches and {earlier_name} is evaluated first"
166                        ),
167                    });
168                }
169            }
170        }
171    }
172}
173
174/// A positive pattern that an exclusion of the same list fully covers matches
175/// nothing: exclusions apply whatever their place in the list.
176fn excluded_positives(group: &RuleGroup, out: &mut Vec<Warning>) {
177    for kind in Kind::ALL {
178        let list = group.patterns(kind);
179        for target in list.iter().filter(|p| !p.starts_with('!')) {
180            let by = list.iter().find(|p| {
181                let (negated, body) = pattern::split_negation(p);
182                negated && covers(kind, body, target)
183            });
184            if let Some(by) = by {
185                out.push(Warning {
186                    rule: group.id.clone(),
187                    message: format!(
188                        "{kind} pattern `{target}` is unreachable: exclusion `{by}` in the same \
189                         list removes everything it matches, wherever it is placed"
190                    ),
191                });
192            }
193        }
194    }
195}
196
197/// A `fetch` exclusion does not narrow the group's `net` list, which matches
198/// fetches too: a fetch it names still matches a `net` pattern of the group.
199fn fetch_exclusions_net_matches(group: &RuleGroup, out: &mut Vec<Warning>) {
200    let net = group.patterns(Kind::Net);
201    for excluded in group.patterns(Kind::Fetch) {
202        let (true, body) = pattern::split_negation(excluded) else {
203            continue;
204        };
205        let net_excludes = net.iter().any(|p| {
206            let (negated, by) = pattern::split_negation(p);
207            negated && covers(Kind::Net, by, body)
208        });
209        let by = net
210            .iter()
211            .find(|p| !p.starts_with('!') && covers(Kind::Net, p, body));
212        if let Some(by) = by.filter(|_| !net_excludes) {
213            out.push(Warning {
214                rule: group.id.clone(),
215                message: format!(
216                    "fetch exclusion `{excluded}` does not stop fetches of `{body}`: net pattern \
217                     `{by}` of the same rule matches them too; add `{excluded}` to `net` as well"
218                ),
219            });
220        }
221    }
222}
223
224/// Does pattern `by` match everything pattern `target` matches?
225fn covers(kind: Kind, by: &str, target: &str) -> bool {
226    if kind == Kind::Shell {
227        match (ShellPattern::compile(by), ShellPattern::compile(target)) {
228            (Ok(by), Ok(target)) => by.covers(&target),
229            _ => false,
230        }
231    } else {
232        matches!((glob(by), glob(target)), (Some(by), Some(target)) if by.covers(&target))
233    }
234}
235
236fn glob(raw: &str) -> Option<GlobPattern> {
237    GlobPattern::compile(&paths::expand_pattern(raw, HOME, Some(PROJECT))?, false).ok()
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn warn(yaml: &str) -> Vec<String> {
245        warnings(&Policy::parse(yaml).unwrap())
246            .iter()
247            .map(ToString::to_string)
248            .collect()
249    }
250
251    #[test]
252    fn ask_shadowed_by_allow_shell_prefix() {
253        let w = warn(
254            "version: 1\nallow:\n  - id: dev\n    shell: ['cargo *']\n\
255             ask:\n  - id: push\n    shell: ['cargo publish*', 'git push*']\n",
256        );
257        assert_eq!(w.len(), 1, "{w:?}");
258        assert!(w[0].contains("rule `push`") && w[0].contains("`cargo publish*`"));
259        assert!(w[0].contains("rule `dev`") && w[0].contains("`cargo *`"));
260    }
261
262    #[test]
263    fn allow_shadowed_by_deny_glob() {
264        let w = warn(
265            "version: 1\ndeny:\n  - id: secrets\n    fs.read: ['~/.ssh/**']\n\
266             allow:\n  - id: keys\n    fs.read: ['~/.ssh/config', '${project}/**']\n",
267        );
268        assert_eq!(w.len(), 1, "{w:?}");
269        assert!(w[0].contains("allow fs.read pattern `~/.ssh/config`"));
270    }
271
272    #[test]
273    fn net_patterns_shadow_fetch_patterns_but_not_the_reverse() {
274        let w = warn(
275            "version: 1\nallow:\n  - id: reg\n    net: ['*.crates.io']\n    fetch: ['docs.rs']\n\
276             ask:\n  - id: web\n    fetch: ['static.crates.io']\n    net: ['docs.rs']\n",
277        );
278        assert_eq!(w.len(), 1, "{w:?}");
279        assert!(
280            w[0].contains("ask fetch pattern `static.crates.io`"),
281            "{w:?}"
282        );
283        assert!(
284            w[0].contains("rule `reg` net pattern `*.crates.io`"),
285            "{w:?}"
286        );
287    }
288
289    #[test]
290    fn negated_lists_are_not_assumed_to_cover() {
291        let w = warn(
292            "version: 1\nallow:\n  - id: proj\n    fs.write: ['${project}/**', '!${project}/.git/**']\n\
293             ask:\n  - id: git\n    fs.write: ['${project}/.git/**']\n",
294        );
295        assert!(w.is_empty(), "{w:?}");
296    }
297
298    #[test]
299    fn a_positive_inside_an_exclusion_of_its_list_is_reported() {
300        let w = warn(
301            "version: 1\nallow:\n  - id: env\n    fs.read: ['!**/.env', '**/.env', '${project}/**']\n",
302        );
303        assert_eq!(w.len(), 1, "{w:?}");
304        assert!(
305            w[0].contains("fs.read pattern `**/.env` is unreachable"),
306            "{w:?}"
307        );
308        assert!(w[0].contains("exclusion `!**/.env`"), "{w:?}");
309    }
310
311    #[test]
312    fn a_fetch_exclusion_a_net_pattern_still_matches_is_reported() {
313        let w = warn(
314            "version: 1\nallow:\n  - id: web\n    net: ['*.example']\n    fetch: ['!api.example']\n",
315        );
316        assert_eq!(w.len(), 1, "{w:?}");
317        assert!(
318            w[0].contains("rule `web`: fetch exclusion `!api.example`"),
319            "{w:?}"
320        );
321        assert!(w[0].contains("net pattern `*.example`"), "{w:?}");
322        for quiet in [
323            "net: ['*.example', '!api.example']\n    fetch: ['!api.example']",
324            "net: ['docs.example']\n    fetch: ['!api.example']",
325        ] {
326            let w = warn(&format!("version: 1\nallow:\n  - id: web\n    {quiet}\n"));
327            assert!(w.is_empty(), "{quiet}: {w:?}");
328        }
329    }
330
331    #[test]
332    fn reserved_blocks_are_reported() {
333        let w =
334            warn("version: 1\napproval:\n  channel: telegram\nscope:\n  project_roots: ['.']\n");
335        assert_eq!(w.len(), 2, "{w:?}");
336        assert!(w[0].contains("`approval` is reserved") && w[1].contains("`scope` is reserved"));
337        assert!(warn("version: 1\n").is_empty());
338    }
339
340    #[test]
341    fn unknown_default_kinds() {
342        let w = warn("version: 1\ndefaults: { network: deny, net: deny, '*': ask }\n");
343        assert_eq!(w.len(), 1, "{w:?}");
344        assert!(w[0].contains("`network`"));
345        assert!(warn("version: 1\ndefaults: allow\n").is_empty());
346    }
347
348    #[test]
349    fn secrets_the_proxy_refuses_or_the_agent_can_read() {
350        let secrets = "secrets:\n  - id: gh\n    host: api.github.com\n    header: Authorization\n    \
351                       source: { file: ~/.config/moat/gh }\n  - id: npm\n    host: registry.npmjs.org\n    \
352                       header: Authorization\n    source: { env: NPM_TOKEN }\n";
353        let w = warn(&format!("version: 1\n{secrets}"));
354        assert_eq!(w.len(), 4, "{w:?}");
355        assert!(
356            w[0].contains("secrets.gh") && w[0].contains("refuses it"),
357            "{w:?}"
358        );
359        assert!(
360            w[1].contains("`~/.config/moat/gh`, so the agent can read it"),
361            "{w:?}"
362        );
363        assert!(
364            w[3].contains("`NPM_TOKEN`, so the agent can read it"),
365            "{w:?}"
366        );
367        let covered = warn(&format!(
368            "version: 1\ndeny:\n  - id: s\n    fs.read: ['~/.config/moat/**']\n    env.read: ['*_TOKEN']\n\
369             allow:\n  - id: n\n    net: ['api.github.com', '*.npmjs.org']\n{secrets}"
370        ));
371        assert!(covered.is_empty(), "{covered:?}");
372        let excluded = warn(&format!(
373            "version: 1\ndeny:\n  - id: s\n    fs.read: ['~/.config/**', '!~/.config/moat/**']\n    \
374             env.read: ['*']\nallow:\n  - id: n\n    net: ['*', '!api.github.com']\n{secrets}"
375        ));
376        assert_eq!(excluded.len(), 2, "{excluded:?}");
377    }
378
379    #[test]
380    fn plain_http_injection_off_this_machine_is_reported() {
381        let policy = |host: &str| {
382            format!(
383                "version: 1\ndeny:\n  - id: s\n    env.read: ['*']\nallow:\n  - id: n\n    net: ['*']\n\
384                 secrets:\n  - id: s\n    host: {host}\n    header: X-K\n    source: {{ env: K }}\n    \
385                 plain_http: true\n"
386            )
387        };
388        let w = warn(&policy("api.example.com"));
389        assert_eq!(w.len(), 1, "{w:?}");
390        assert!(w[0].contains("in clear text"), "{w:?}");
391        assert!(warn(&policy("localhost")).is_empty());
392        assert!(warn(&policy("127.0.0.1")).is_empty());
393    }
394}