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