1use 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
17const HOME: &str = "/__home__";
20const PROJECT: &str = "/__project__";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Warning {
25 pub rule: String,
27 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#[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
72fn 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#[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
117fn 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
147fn 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
187fn 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
210fn 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
237fn 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}