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