1use argon2::PasswordHash;
11
12use crate::config::Config;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Level {
18 Error,
19 Warn,
20 Info,
21}
22
23impl Level {
24 pub fn glyph(self) -> &'static str {
26 match self {
27 Level::Error => "✗",
28 Level::Warn => "⚠",
29 Level::Info => "ℹ",
30 }
31 }
32 pub fn label(self) -> &'static str {
33 match self {
34 Level::Error => "error",
35 Level::Warn => "warn",
36 Level::Info => "info",
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct Finding {
44 pub level: Level,
45 pub message: String,
46}
47
48impl Finding {
49 fn error(msg: impl Into<String>) -> Finding {
50 Finding {
51 level: Level::Error,
52 message: msg.into(),
53 }
54 }
55 fn warn(msg: impl Into<String>) -> Finding {
56 Finding {
57 level: Level::Warn,
58 message: msg.into(),
59 }
60 }
61 fn info(msg: impl Into<String>) -> Finding {
62 Finding {
63 level: Level::Info,
64 message: msg.into(),
65 }
66 }
67}
68
69pub fn lint(cfg: &Config) -> Vec<Finding> {
72 let mut f = Vec::new();
73 lint_auth(cfg, &mut f);
74 lint_ratelimit(cfg, &mut f);
75 lint_tls(cfg, &mut f);
76 lint_cors(cfg, &mut f);
77 lint_forwarded(cfg, &mut f);
78 lint_secrets(cfg, &mut f);
79 lint_control_plane(cfg, &mut f);
80 f
81}
82
83fn lint_auth(cfg: &Config, f: &mut Vec<Finding>) {
84 match cfg.auth.mode.as_str() {
85 "none" => f.push(Finding::warn(
86 "auth.mode = \"none\": every request is forwarded unauthenticated. Set a gate \
87 (basic/apikey/jwt) before exposing this.",
88 )),
89 "basic" => {
90 if cfg.auth.users.is_empty() {
91 f.push(Finding::error(
92 "auth.mode = \"basic\" but auth.users is empty: no one can authenticate.",
93 ));
94 }
95 for (user, value) in &cfg.auth.users {
96 if value.starts_with("$argon2") {
97 if PasswordHash::new(value).is_err() {
100 f.push(Finding::error(format!(
101 "auth.users[\"{user}\"] is not a valid argon2 hash (the shipped \
102 placeholder?): no one can authenticate. Run `edgeguard --hash` and \
103 paste the result."
104 )));
105 }
106 } else {
107 f.push(Finding::warn(format!(
108 "auth.users[\"{user}\"] is a plaintext password (dev convenience). Replace \
109 it with an argon2 hash (`edgeguard --hash`) before exposing anything."
110 )));
111 }
112 }
113 }
114 "apikey" => {
115 if cfg.auth.api_keys.is_empty() {
116 f.push(Finding::error(
117 "auth.mode = \"apikey\" but no api_keys are set (config or EDGEGUARD_API_KEYS): \
118 no request can authenticate.",
119 ));
120 }
121 }
122 "jwt" => {
123 let j = &cfg.auth.jwt;
124 if j.secret.is_empty() && j.public_key_pem.is_empty() && j.jwks_url.is_empty() {
125 f.push(Finding::error(
126 "auth.mode = \"jwt\" but none of auth.jwt.secret / public_key_pem / jwks_url \
127 is set: tokens cannot be verified.",
128 ));
129 }
130 }
131 _ => {} }
133}
134
135fn lint_ratelimit(cfg: &Config, f: &mut Vec<Finding>) {
136 let rl = &cfg.ratelimit;
137 if !rl.enabled {
138 f.push(Finding::warn(
139 "ratelimit.enabled = false: no rate limiting. A public front door usually wants a \
140 per-IP cap to blunt abuse/brute-force.",
141 ));
142 return;
143 }
144 if rl.store == "redis" && rl.redis_url.trim().is_empty() {
145 f.push(Finding::error(
146 "ratelimit.store = \"redis\" but redis_url is empty (set it or EDGEGUARD_REDIS_URL).",
147 ));
148 }
149}
150
151fn lint_tls(cfg: &Config, f: &mut Vec<Finding>) {
152 if !cfg.tls.enabled {
153 f.push(Finding::info(
154 "tls.enabled = false: EdgeGuard serves plain HTTP. Fine when your platform terminates \
155 TLS in front of it; on a VPS/front-proxy, enable [tls] (or [tls.acme]) so traffic \
156 isn't unencrypted.",
157 ));
158 }
159}
160
161fn lint_cors(cfg: &Config, f: &mut Vec<Finding>) {
162 let c = &cfg.cors;
163 if !c.enabled {
164 return;
165 }
166 let wildcard = c.allow_origins.iter().any(|o| o.trim() == "*");
167 if wildcard && c.allow_credentials {
168 f.push(Finding::error(
170 "cors.allow_credentials = true cannot be combined with a \"*\" origin; list explicit \
171 origins instead.",
172 ));
173 } else if wildcard {
174 f.push(Finding::warn(
175 "cors.allow_origins = [\"*\"]: any website may make cross-origin requests and read \
176 responses. Prefer an explicit origin list.",
177 ));
178 }
179}
180
181fn lint_forwarded(cfg: &Config, f: &mut Vec<Finding>) {
182 if cfg.server.trust_forwarded_for {
183 f.push(Finding::info(
184 "server.trust_forwarded_for = true: only correct when EdgeGuard is behind a trusted \
185 proxy/LB that sets X-Forwarded-For. If it's directly reachable, clients can spoof \
186 their IP and defeat per-IP rate limiting.",
187 ));
188 }
189}
190
191fn lint_secrets(cfg: &Config, f: &mut Vec<Finding>) {
192 if !cfg.auth.jwt.secret.is_empty() && !env_sourced("EDGEGUARD_JWT_SECRET") {
198 f.push(Finding::info(
199 "auth.jwt.secret is set in the config file; prefer the EDGEGUARD_JWT_SECRET env var (or \
200 EDGEGUARD_JWT_SECRET_FILE) so the secret isn't committed.",
201 ));
202 }
203 if !cfg.auth.api_keys.is_empty() && !env_sourced("EDGEGUARD_API_KEYS") {
204 f.push(Finding::info(
205 "auth.api_keys are listed in the config file; prefer the EDGEGUARD_API_KEYS env var (or \
206 EDGEGUARD_API_KEYS_FILE).",
207 ));
208 }
209 if !cfg.control_plane.edge_token.is_empty() && !env_sourced("EDGEGUARD_CP_EDGE_TOKEN") {
210 f.push(Finding::info(
211 "control_plane.edge_token is set in the config file; prefer EDGEGUARD_CP_EDGE_TOKEN (or \
212 EDGEGUARD_CP_EDGE_TOKEN_FILE).",
213 ));
214 }
215}
216
217fn env_sourced(name: &str) -> bool {
220 let nonempty = |k: String| std::env::var(k).is_ok_and(|v| !v.is_empty());
221 nonempty(name.to_string()) || nonempty(format!("{name}_FILE"))
222}
223
224fn lint_control_plane(cfg: &Config, f: &mut Vec<Finding>) {
225 if cfg.control_plane.enforce_quota && !cfg.control_plane.enabled {
226 f.push(Finding::error(
227 "control_plane.enforce_quota = true requires control_plane.enabled = true (with \
228 url/tenant_id/edge_token); otherwise the quota gate can never be evaluated.",
229 ));
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::collections::BTreeMap;
237
238 fn has_error(f: &[Finding]) -> bool {
239 f.iter().any(|x| x.level == Level::Error)
240 }
241
242 #[test]
243 fn default_config_has_no_errors() {
244 let f = lint(&Config::default());
246 assert!(!has_error(&f), "{f:?}");
247 assert!(f.iter().any(|x| x.message.contains("auth.mode = \"none\"")));
248 }
249
250 #[test]
251 fn placeholder_basic_credential_is_an_error() {
252 let mut cfg = Config::default();
253 cfg.auth.mode = "basic".into();
254 let mut users = BTreeMap::new();
255 users.insert(
256 "admin".to_string(),
257 "$argon2id$REPLACE_ME$run-edgeguard---hash".to_string(),
258 );
259 cfg.auth.users = users;
260 let f = lint(&cfg);
261 assert!(has_error(&f), "{f:?}");
262 }
263
264 #[test]
265 fn plaintext_basic_password_warns_not_errors() {
266 let mut cfg = Config::default();
267 cfg.auth.mode = "basic".into();
268 let mut users = BTreeMap::new();
269 users.insert("admin".to_string(), "hunter2".to_string());
270 cfg.auth.users = users;
271 let f = lint(&cfg);
272 assert!(!has_error(&f), "{f:?}");
273 assert!(f.iter().any(|x| x.level == Level::Warn));
274 }
275
276 #[test]
277 fn credentialed_wildcard_cors_is_an_error() {
278 let mut cfg = Config::default();
279 cfg.cors.enabled = true;
280 cfg.cors.allow_origins = vec!["*".into()];
281 cfg.cors.allow_credentials = true;
282 assert!(has_error(&lint(&cfg)));
283 }
284
285 #[test]
286 fn jwt_without_any_key_is_an_error() {
287 let mut cfg = Config::default();
288 cfg.auth.mode = "jwt".into();
289 cfg.auth.jwt.secret = String::new();
290 assert!(has_error(&lint(&cfg)));
291 }
292
293 #[test]
294 fn secret_in_config_warns_only_when_not_env_sourced() {
295 let mut cfg = Config::default();
296 cfg.auth.jwt.secret = "shhh".into();
297 let mentions_secret =
298 |f: &[Finding]| f.iter().any(|x| x.message.contains("auth.jwt.secret"));
299
300 std::env::remove_var("EDGEGUARD_JWT_SECRET");
302 std::env::remove_var("EDGEGUARD_JWT_SECRET_FILE");
303 assert!(mentions_secret(&lint(&cfg)));
304
305 std::env::set_var("EDGEGUARD_JWT_SECRET", "shhh");
307 assert!(!mentions_secret(&lint(&cfg)));
308 std::env::remove_var("EDGEGUARD_JWT_SECRET");
309 }
310}