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.allow, &policy.deny, "allow", "deny", &mut out);
57 secrets(policy, &mut out);
58 out
59}
60
61fn 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
100fn 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
130fn 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
170fn 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}