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