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