1use serde_json::{json, Value};
23use std::collections::{HashMap, HashSet};
24use std::net::IpAddr;
25
26pub struct Finding {
29 pub rule: &'static str,
30 pub severity: &'static str,
31 pub chunk_id: String,
32 pub chunk_name: String,
33 pub chunk_type: String,
34 pub field: String,
35 pub message: String,
36 pub related: Vec<String>,
38}
39
40impl Finding {
41 pub fn to_json(&self) -> Value {
42 json!({
43 "rule": self.rule,
44 "severity": self.severity,
45 "chunk_id": self.chunk_id,
46 "chunk": self.chunk_name,
47 "chunk_type": self.chunk_type,
48 "field": self.field,
49 "message": self.message,
50 "related": self.related,
51 })
52 }
53}
54
55fn s<'a>(v: &'a Value, key: &str) -> &'a str {
56 v.get(key).and_then(Value::as_str).unwrap_or("")
57}
58
59fn fields(chunk: &Value) -> Vec<&Value> {
60 chunk
61 .get("fields")
62 .and_then(Value::as_array)
63 .map(|a| a.iter().collect())
64 .unwrap_or_default()
65}
66
67fn field<'a>(chunk: &'a Value, key: &str) -> &'a str {
69 fields(chunk)
70 .into_iter()
71 .find(|f| s(f, "key") == key && !s(f, "value").trim().is_empty())
72 .map(|f| s(f, "value").trim())
73 .unwrap_or("")
74}
75
76fn field_all<'a>(chunk: &'a Value, key: &str) -> Vec<&'a str> {
78 fields(chunk)
79 .into_iter()
80 .filter(|f| s(f, "key") == key)
81 .map(|f| s(f, "value").trim())
82 .filter(|v| !v.is_empty())
83 .collect()
84}
85
86fn split_list(raw: &str) -> Vec<String> {
87 raw.split(|c: char| c == ',' || c.is_whitespace())
88 .map(str::trim)
89 .filter(|x| !x.is_empty())
90 .map(String::from)
91 .collect()
92}
93
94fn active_chunks(project: &Value) -> Vec<&Value> {
96 project
97 .get("chunks")
98 .and_then(Value::as_array)
99 .map(|a| {
100 a.iter()
101 .filter(|c| !c.get("disabled").and_then(Value::as_bool).unwrap_or(false))
102 .collect()
103 })
104 .unwrap_or_default()
105}
106
107fn of_type<'a>(chunks: &[&'a Value], t: &str) -> Vec<&'a Value> {
108 chunks
109 .iter()
110 .copied()
111 .filter(|c| s(c, "chunk_type") == t)
112 .collect()
113}
114
115fn finding(
116 rule: &'static str,
117 severity: &'static str,
118 chunk: &Value,
119 field: &str,
120 message: String,
121 related: Vec<String>,
122) -> Finding {
123 Finding {
124 rule,
125 severity,
126 chunk_id: s(chunk, "id").to_string(),
127 chunk_name: s(chunk, "name").to_string(),
128 chunk_type: s(chunk, "chunk_type").to_string(),
129 field: field.to_string(),
130 message,
131 related,
132 }
133}
134
135fn service_name(chunk: &Value) -> String {
137 s(chunk, "name")
138 .split_whitespace()
139 .collect::<Vec<_>>()
140 .join("_")
141 .to_lowercase()
142}
143
144fn service_aliases(chunk: &Value) -> Vec<String> {
146 let mut v = vec![service_name(chunk)];
147 let cn = field(chunk, "container_name").to_lowercase();
148 if !cn.is_empty() {
149 v.push(cn);
150 }
151 v
152}
153
154fn norm(name: &str) -> String {
155 name.chars()
156 .map(|c| {
157 if c.is_ascii_alphanumeric() {
158 c.to_ascii_uppercase()
159 } else {
160 '_'
161 }
162 })
163 .collect()
164}
165
166fn is_ref(v: &str) -> bool {
167 v.contains("${")
168}
169
170fn rule_nginx_proxy_pass(chunks: &[&Value], elsewhere: &[String], out: &mut Vec<Finding>) {
173 let services = of_type(chunks, "docker_service");
174 if services.is_empty() && elsewhere.is_empty() {
175 return; }
177 let mut known: HashSet<String> = services.iter().flat_map(|c| service_aliases(c)).collect();
178 known.extend(elsewhere.iter().cloned());
179 for u in of_type(chunks, "nginx_upstream") {
180 known.insert(s(u, "name").to_lowercase());
181 }
182 for t in ["nginx_location", "nginx_server"] {
183 for c in of_type(chunks, t) {
184 for target in field_all(c, "proxy_pass") {
185 let Some(host) = proxy_host(target) else {
186 continue;
187 };
188 if !known.contains(&host) {
189 out.push(finding(
190 "nginx-proxy-pass-unknown-service",
191 "warning",
192 c,
193 "proxy_pass",
194 format!(
195 "proxy_pass names `{host}`, which is neither a Docker service nor an upstream defined in this project"
196 ),
197 services.iter().map(|x| service_name(x)).collect(),
198 ));
199 }
200 }
201 }
202 }
203}
204
205fn proxy_host(target: &str) -> Option<String> {
208 if is_ref(target) || target.contains('$') || target.starts_with("unix:") {
209 return None;
210 }
211 let rest = target.split_once("://").map(|(_, r)| r).unwrap_or(target);
212 if rest.starts_with("unix:") {
213 return None;
214 }
215 let host = rest.split(['/', ':']).next().unwrap_or("").to_lowercase();
216 if host.is_empty()
217 || host == "localhost"
218 || host.contains('.')
219 || host.contains('[')
220 || host.parse::<IpAddr>().is_ok()
221 {
222 return None;
223 }
224 Some(host)
225}
226
227fn network(cidr: &str) -> Option<(u8, u128, u8)> {
231 let (addr, len) = match cidr.split_once('/') {
232 Some((a, l)) => (a, Some(l.parse::<u8>().ok()?)),
233 None => (cidr, None),
234 };
235 match addr.parse::<IpAddr>().ok()? {
236 IpAddr::V4(a) => {
237 let len = len.unwrap_or(32);
238 if len > 32 {
239 return None;
240 }
241 let bits = u32::from(a) as u128;
242 let mask = if len == 0 {
243 0
244 } else {
245 (!0u32 << (32 - len)) as u128
246 };
247 Some((4, bits & mask, len))
248 }
249 IpAddr::V6(a) => {
250 let len = len.unwrap_or(128);
251 if len > 128 {
252 return None;
253 }
254 let bits = u128::from(a);
255 let mask = if len == 0 { 0 } else { !0u128 << (128 - len) };
256 Some((6, bits & mask, len))
257 }
258 }
259}
260
261fn contains(a: (u8, u128, u8), b: (u8, u128, u8)) -> bool {
263 if a.0 != b.0 || a.2 >= b.2 {
264 return false;
265 }
266 let width: u32 = if a.0 == 4 { 32 } else { 128 };
267 let shift = width - a.2 as u32;
268 if a.2 == 0 {
270 return true;
271 }
272 (a.1 >> shift) == (b.1 >> shift)
273}
274
275fn rule_wireguard_allowed_ips(chunks: &[&Value], out: &mut Vec<Finding>) {
281 let peers = of_type(chunks, "wg_peer");
282 let mut seen: HashMap<(u8, u128, u8), &Value> = HashMap::new();
283 let mut nets: Vec<((u8, u128, u8), String, &Value)> = Vec::new();
284 for p in peers {
285 let mut mine: HashSet<(u8, u128, u8)> = HashSet::new();
286 for raw in field_all(p, "AllowedIPs") {
287 if is_ref(raw) {
288 continue;
289 }
290 for item in split_list(raw) {
291 let Some(net) = network(&item) else { continue };
292 if !mine.insert(net) {
293 continue;
294 }
295 if let Some(other) = seen.get(&net) {
296 out.push(finding(
297 "wireguard-allowed-ips-duplicate",
298 "error",
299 p,
300 "AllowedIPs",
301 format!(
302 "AllowedIPs `{item}` is also claimed by peer `{}`; WireGuard gives the address to only one of them",
303 s(other, "name")
304 ),
305 vec![s(other, "name").to_string()],
306 ));
307 } else {
308 seen.insert(net, p);
309 nets.push((net, item.clone(), p));
310 }
311 }
312 }
313 }
314 for (outer, outer_text, op) in &nets {
316 if outer.2 == 0 {
317 continue;
318 }
319 for (inner, inner_text, ip) in &nets {
320 if std::ptr::eq(*op, *ip) || !contains(*outer, *inner) {
321 continue;
322 }
323 out.push(finding(
324 "wireguard-allowed-ips-overlap",
325 "warning",
326 ip,
327 "AllowedIPs",
328 format!(
329 "AllowedIPs `{inner_text}` sits inside `{outer_text}` claimed by peer `{}`; WireGuard sends it to this peer (longest prefix), so the wider peer never sees it",
330 s(op, "name")
331 ),
332 vec![s(op, "name").to_string()],
333 ));
334 }
335 }
336}
337
338fn rule_traefik_middleware(chunks: &[&Value], out: &mut Vec<Finding>) {
341 let defined: HashSet<String> = of_type(chunks, "traefik_middleware")
342 .iter()
343 .map(|m| s(m, "name").to_lowercase())
344 .collect();
345 for router in of_type(chunks, "traefik_router") {
346 for raw in field_all(router, "middlewares") {
347 if is_ref(raw) {
348 continue;
349 }
350 for name in split_list(raw) {
351 if name.contains('@') || defined.contains(&name.to_lowercase()) {
353 continue;
354 }
355 out.push(finding(
356 "traefik-middleware-missing",
357 "warning",
358 router,
359 "middlewares",
360 format!(
361 "router uses middleware `{name}`, which no traefik_middleware chunk defines (write `{name}@file` if it lives in another file)"
362 ),
363 vec![],
364 ));
365 }
366 }
367 }
368}
369
370fn split_list_k8s(raw: &str) -> Vec<String> {
379 split_list(raw)
380}
381
382fn rule_k8s_secrets(chunks: &[&Value], out: &mut Vec<Finding>) {
383 let secrets = of_type(chunks, "k8s_secret");
384 if secrets.is_empty() {
385 return;
386 }
387 let defined: HashSet<(String, String)> =
388 secrets.iter().map(|c| (k8s_name(c), k8s_ns(c))).collect();
389 for dep in of_type(chunks, "k8s_deployment") {
390 let ns = k8s_ns(dep);
391 let mut wanted: Vec<(&str, String)> = Vec::new();
392 for raw in field_all(dep, "secretEnv") {
393 for n in split_list_k8s(raw) {
394 wanted.push(("secretEnv", n));
395 }
396 }
397 for raw in field_all(dep, "secretMounts") {
398 for m in split_list_k8s(raw) {
399 if let Some(i) = m.find(':').filter(|i| *i > 0 && *i < m.len() - 1) {
400 wanted.push(("secretMounts", m[..i].to_string()));
401 }
402 }
403 }
404 let mut reported: HashSet<String> = HashSet::new();
405 for (key, n) in wanted {
406 if is_ref(&n)
407 || defined.contains(&(n.clone(), ns.clone()))
408 || !reported.insert(n.clone())
409 {
410 continue;
411 }
412 out.push(finding(
413 "k8s-deployment-secret-missing",
414 "error",
415 dep,
416 key,
417 format!("Deployment uses Secret `{n}` in namespace `{ns}`, which no k8s_secret chunk creates; the Pod will not start"),
418 secrets.iter().map(|c| k8s_name(c)).collect(),
419 ));
420 }
421 }
422}
423
424fn k8s_name(chunk: &Value) -> String {
427 let n = field(chunk, "name");
428 if n.is_empty() { s(chunk, "name") } else { n }.to_string()
429}
430
431fn k8s_ns(chunk: &Value) -> String {
432 let n = field(chunk, "namespace");
433 if n.is_empty() { "default" } else { n }.to_string()
434}
435
436fn rule_k8s_ingress(chunks: &[&Value], out: &mut Vec<Finding>) {
437 let services = of_type(chunks, "k8s_service");
438 if services.is_empty() {
439 return;
440 }
441 let defined: HashSet<(String, String)> =
442 services.iter().map(|c| (k8s_name(c), k8s_ns(c))).collect();
443 for ing in of_type(chunks, "k8s_ingress") {
444 let svc = {
445 let v = field(ing, "serviceName");
446 if v.is_empty() {
447 k8s_name(ing)
448 } else {
449 v.to_string()
450 }
451 };
452 if is_ref(&svc) {
453 continue;
454 }
455 let ns = k8s_ns(ing);
456 if !defined.contains(&(svc.clone(), ns.clone())) {
457 out.push(finding(
458 "k8s-ingress-service-missing",
459 "warning",
460 ing,
461 "serviceName",
462 format!("Ingress routes to Service `{svc}` in namespace `{ns}`, which no k8s_service chunk defines"),
463 services.iter().map(|c| k8s_name(c)).collect(),
464 ));
465 }
466 }
467}
468
469fn compose_tokens(value: &str) -> Vec<String> {
482 let b = value.as_bytes();
483 let mut out = Vec::new();
484 let mut i = 0;
485 while i + 1 < b.len() {
486 if b[i] == b'$' && b[i + 1] == b'$' {
487 i += 2;
488 continue;
489 }
490 if b[i] == b'$' && b[i + 1] == b'{' {
491 if let Some(end) = value[i + 2..].find('}') {
492 let body = &value[i + 2..i + 2 + end];
493 if !body.is_empty()
494 && !body.contains(":-")
495 && !body.contains(":?")
496 && !body.contains('-')
497 && !body.contains('?')
498 && !body.contains('+')
499 {
500 out.push(body.to_string());
501 }
502 i += 2 + end + 1;
503 continue;
504 }
505 }
506 i += 1;
507 }
508 out
509}
510
511fn rule_compose_env(chunks: &[&Value], known: &[String], out: &mut Vec<Finding>) {
513 let env_keys: HashSet<String> = of_type(chunks, "env_file")
514 .iter()
515 .flat_map(|c| fields(c))
516 .map(|f| norm(s(f, "key")))
517 .collect();
518 let providers: Vec<String> = known.iter().map(|k| norm(k)).collect();
519 let resolves = |name: &str| -> bool {
520 if name.starts_with("chunk:") || name.starts_with("bundle:") {
521 return true; }
523 let head = name.split('/').next().unwrap_or(name);
524 let n = norm(head);
525 env_keys.contains(&n)
526 || providers
527 .iter()
528 .any(|p| !p.is_empty() && (n == *p || n.starts_with(&format!("{p}_"))))
529 };
530 for svc in of_type(chunks, "docker_service") {
531 for f in fields(svc) {
532 let raw = s(f, "value");
533 let mut names: Vec<String> = Vec::new();
534 if !s(f, "ref_name").is_empty() {
535 names.push(s(f, "ref_name").to_string());
536 }
537 names.extend(compose_tokens(raw));
538 let mut reported: HashSet<String> = HashSet::new();
539 for name in names {
540 if resolves(&name) || !reported.insert(name.clone()) {
541 continue;
542 }
543 out.push(finding(
544 "compose-env-ref-unresolved",
545 "warning",
546 svc,
547 s(f, "key"),
548 format!(
549 "`{}` reads `${{{name}}}`, which is neither a vault entry nor a key in an env_file chunk",
550 s(f, "key")
551 ),
552 vec![],
553 ));
554 }
555 }
556 }
557}
558
559fn networks_of(svc: &Value) -> Option<HashSet<String>> {
562 if !field(svc, "network_mode").is_empty() {
564 return None;
565 }
566 let listed: HashSet<String> = field_all(svc, "networks")
567 .iter()
568 .flat_map(|v| split_list(v))
569 .collect();
570 Some(if listed.is_empty() {
571 HashSet::from(["default".to_string()])
572 } else {
573 listed
574 })
575}
576
577fn mentions(svc: &Value, name: &str) -> bool {
578 if field_all(svc, "depends_on")
579 .iter()
580 .flat_map(|v| split_list(v))
581 .any(|d| d.to_lowercase() == name)
582 {
583 return true;
584 }
585 fields(svc)
586 .into_iter()
587 .filter(|f| s(f, "description") == "env" || s(f, "field_type") == "env_var")
588 .any(|f| {
589 s(f, "value")
590 .to_lowercase()
591 .split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')))
592 .any(|tok| tok == name)
593 })
594}
595
596fn reads_chunk(svc: &Value, pg_name: &str) -> bool {
599 let needle = format!("chunk:{}", pg_name.to_lowercase());
600 fields(svc).into_iter().any(|f| {
601 let v = s(f, "value").to_lowercase();
602 let r = s(f, "ref_name").to_lowercase();
603 v.contains(&needle) || r.contains(&needle)
604 })
605}
606
607fn rule_pg_network(chunks: &[&Value], out: &mut Vec<Finding>) {
608 let services = of_type(chunks, "docker_service");
609 for pg in of_type(chunks, "pg_connection") {
610 let host = field(pg, "host").to_lowercase();
611 if host.is_empty() || is_ref(&host) {
612 continue;
613 }
614 let Some(db) = services.iter().find(|c| service_aliases(c).contains(&host)) else {
615 continue;
616 };
617 let Some(db_nets) = networks_of(db) else {
618 continue;
619 };
620 for c in &services {
621 if std::ptr::eq(*c, *db) {
622 continue;
623 }
624 let Some(c_nets) = networks_of(c) else {
625 continue;
626 };
627 let reaches =
628 service_aliases(db).iter().any(|a| mentions(c, a)) || reads_chunk(c, s(pg, "name"));
629 if reaches && c_nets.is_disjoint(&db_nets) {
630 out.push(finding(
631 "pg-host-network-unreachable",
632 "warning",
633 pg,
634 "host",
635 format!(
636 "host `{host}` is Docker service `{}`, but service `{}` uses it and shares no network with it",
637 service_name(db),
638 service_name(c)
639 ),
640 vec![service_name(db), service_name(c)],
641 ));
642 }
643 }
644 }
645}
646
647fn rule_traefik_service(chunks: &[&Value], out: &mut Vec<Finding>) {
656 let defined: HashSet<String> = of_type(chunks, "traefik_service")
657 .iter()
658 .map(|c| s(c, "name").to_lowercase())
659 .collect();
660 if defined.is_empty() {
661 return;
662 }
663 for router in of_type(chunks, "traefik_router") {
664 for raw in field_all(router, "service") {
665 if is_ref(raw) || raw.contains('@') || defined.contains(&raw.to_lowercase()) {
666 continue;
667 }
668 out.push(finding(
669 "traefik-service-missing",
670 "warning",
671 router,
672 "service",
673 format!(
674 "router names service `{raw}`, which no traefik_service chunk defines (write `{raw}@file` if it lives in another file)"
675 ),
676 vec![],
677 ));
678 }
679 }
680}
681
682fn rule_nginx_upstream_unused(chunks: &[&Value], out: &mut Vec<Finding>) {
686 let users: Vec<&Value> = ["nginx_location", "nginx_server"]
687 .iter()
688 .flat_map(|t| of_type(chunks, t))
689 .collect();
690 if users.is_empty() {
691 return;
692 }
693 let mut used = HashSet::new();
694 for c in &users {
695 for target in field_all(c, "proxy_pass") {
696 if is_ref(target) {
697 return; }
699 if let Some(h) = proxy_host(target) {
700 used.insert(h);
701 }
702 }
703 }
704 for u in of_type(chunks, "nginx_upstream") {
705 let name = s(u, "name").to_lowercase();
706 if !name.is_empty() && !used.contains(&name) {
707 out.push(finding(
708 "nginx-upstream-unused",
709 "warning",
710 u,
711 "name",
712 format!("upstream `{name}` is not named by any proxy_pass in this project"),
713 vec![],
714 ));
715 }
716 }
717}
718
719fn rule_compose_depends_on(chunks: &[&Value], out: &mut Vec<Finding>) {
722 let services = of_type(chunks, "docker_service");
723 if services.is_empty() {
724 return;
725 }
726 let known: HashSet<String> = services.iter().flat_map(|c| service_aliases(c)).collect();
727 for svc in &services {
728 for raw in field_all(svc, "depends_on") {
729 if is_ref(raw) {
730 continue;
731 }
732 for dep in split_list(raw) {
733 if !known.contains(&dep.to_lowercase()) {
734 out.push(finding(
735 "compose-depends-on-unknown-service",
736 "error",
737 svc,
738 "depends_on",
739 format!("depends_on names `{dep}`, which is not a service in this project"),
740 vec![],
741 ));
742 }
743 }
744 }
745 }
746}
747
748fn published(spec: &str) -> Option<(String, String, String)> {
751 if is_ref(spec) {
752 return None;
753 }
754 let (body, proto) = spec.split_once('/').unwrap_or((spec, "tcp"));
755 let parts: Vec<&str> = body.split(':').collect();
756 let (ip, host) = match parts.as_slice() {
757 [host, _container] => ("", *host),
758 [ip, host, _container] => (*ip, *host),
759 _ => return None,
760 };
761 if host.is_empty() || !host.chars().all(|c| c.is_ascii_digit()) {
762 return None;
763 }
764 let ip = if ip == "0.0.0.0" { "" } else { ip };
765 Some((ip.to_string(), host.to_string(), proto.to_lowercase()))
766}
767
768fn rule_compose_port_clash(chunks: &[&Value], out: &mut Vec<Finding>) {
770 let services = of_type(chunks, "docker_service");
771 let mut seen: Vec<((String, String, String), &Value)> = Vec::new();
772 for svc in &services {
773 for raw in field_all(svc, "ports") {
774 for spec in split_list(raw) {
775 let Some(p) = published(&spec) else {
776 continue;
777 };
778 let clash = seen.iter().find(|(q, other)| {
779 !std::ptr::eq(*other, *svc)
780 && q.1 == p.1
781 && q.2 == p.2
782 && (q.0 == p.0 || q.0.is_empty() || p.0.is_empty())
783 });
784 if let Some((_, other)) = clash {
785 out.push(finding(
786 "compose-port-clash",
787 "error",
788 svc,
789 "ports",
790 format!(
791 "host port {}/{} is also published by service `{}`",
792 p.1,
793 p.2,
794 service_name(other)
795 ),
796 vec![service_name(other)],
797 ));
798 }
799 seen.push((p, svc));
800 }
801 }
802 }
803}
804
805fn rule_compose_network(chunks: &[&Value], out: &mut Vec<Finding>) {
811 let nets = of_type(chunks, "docker_network");
812 if nets.is_empty() {
813 return;
814 }
815 let mut declared: HashSet<String> = HashSet::from(["default".to_string()]);
816 for n in &nets {
817 let keys: Vec<String> = fields(n)
818 .into_iter()
819 .map(|f| s(f, "key").to_lowercase())
820 .filter(|k| !k.is_empty())
821 .collect();
822 if keys.is_empty() {
823 declared.insert(s(n, "name").to_lowercase());
824 }
825 declared.extend(keys);
826 }
827 for svc in of_type(chunks, "docker_service") {
828 for raw in field_all(svc, "networks") {
829 if is_ref(raw) {
830 continue;
831 }
832 for net in split_list(raw) {
833 if !declared.contains(&net.to_lowercase()) {
834 out.push(finding(
835 "compose-network-undeclared",
836 "error",
837 svc,
838 "networks",
839 format!(
840 "Service joins network `{net}`, which no docker_network chunk declares"
841 ),
842 vec![],
843 ));
844 }
845 }
846 }
847 }
848}
849
850fn rule_k8s_service_selector(chunks: &[&Value], out: &mut Vec<Finding>) {
857 let deps = of_type(chunks, "k8s_deployment");
858 if deps.is_empty() {
859 return;
860 }
861 let defined: HashSet<(String, String)> =
862 deps.iter().map(|c| (k8s_name(c), k8s_ns(c))).collect();
863 for svc in of_type(chunks, "k8s_service") {
864 let (n, ns) = (k8s_name(svc), k8s_ns(svc));
865 if is_ref(&n) || n.is_empty() || defined.contains(&(n.clone(), ns.clone())) {
866 continue;
867 }
868 out.push(finding(
869 "k8s-service-selector-unmatched",
870 "warning",
871 svc,
872 "name",
873 format!("Service `{n}` selects `app: {n}` in namespace `{ns}`, which no k8s_deployment chunk labels; it will have no endpoints"),
874 deps.iter().map(|c| k8s_name(c)).collect(),
875 ));
876 }
877}
878
879pub fn check_project(project: &Value, vault_names: &[String]) -> Vec<Finding> {
883 check_project_scoped(project, vault_names, &[])
884}
885
886pub fn services_of(projects: &[Value]) -> Vec<String> {
890 let mut v: Vec<String> = projects
891 .iter()
892 .flat_map(|p| {
893 let chunks = active_chunks(p);
894 of_type(&chunks, "docker_service")
895 .into_iter()
896 .flat_map(service_aliases)
897 .collect::<Vec<_>>()
898 })
899 .collect();
900 v.sort();
901 v.dedup();
902 v
903}
904
905pub fn check_project_scoped(
909 project: &Value,
910 vault_names: &[String],
911 elsewhere: &[String],
912) -> Vec<Finding> {
913 let chunks = active_chunks(project);
914 let mut out = Vec::new();
915 rule_nginx_proxy_pass(&chunks, elsewhere, &mut out);
916 rule_wireguard_allowed_ips(&chunks, &mut out);
917 rule_traefik_middleware(&chunks, &mut out);
918 rule_k8s_secrets(&chunks, &mut out);
919 rule_k8s_ingress(&chunks, &mut out);
920 rule_compose_env(&chunks, vault_names, &mut out);
921 rule_pg_network(&chunks, &mut out);
922 rule_traefik_service(&chunks, &mut out);
923 rule_nginx_upstream_unused(&chunks, &mut out);
924 rule_compose_depends_on(&chunks, &mut out);
925 rule_compose_port_clash(&chunks, &mut out);
926 rule_compose_network(&chunks, &mut out);
927 rule_k8s_service_selector(&chunks, &mut out);
928 if let Some(a) = crate::stack::adapter(s(project, "project_type")) {
930 out.extend(crate::stack::check(a, project, vault_names));
931 }
932 out
933}
934
935pub fn gate(project: &Value, vault_names: &[String]) -> Vec<Finding> {
938 check_project(project, vault_names)
939 .into_iter()
940 .filter(|f| f.severity == "error")
941 .collect()
942}
943
944pub const RULES: [&str; 14] = [
946 "nginx-proxy-pass-unknown-service",
947 "wireguard-allowed-ips-duplicate",
948 "wireguard-allowed-ips-overlap",
949 "traefik-middleware-missing",
950 "k8s-deployment-secret-missing",
951 "k8s-ingress-service-missing",
952 "compose-env-ref-unresolved",
953 "pg-host-network-unreachable",
954 "traefik-service-missing",
955 "nginx-upstream-unused",
956 "compose-depends-on-unknown-service",
957 "compose-port-clash",
958 "compose-network-undeclared",
959 "k8s-service-selector-unmatched",
960];
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965
966 fn f(key: &str, value: &str) -> Value {
967 json!({"key": key, "value": value, "field_type": "var"})
968 }
969 fn chunk(name: &str, t: &str, fields: Vec<Value>) -> Value {
970 json!({"id": format!("id-{name}"), "name": name, "chunk_type": t, "fields": fields})
971 }
972 fn project(chunks: Vec<Value>) -> Value {
973 json!({"id": "p", "name": "p", "chunks": chunks})
974 }
975 fn rules(p: &Value, names: &[&str]) -> Vec<&'static str> {
976 let names: Vec<String> = names.iter().map(|s| s.to_string()).collect();
977 check_project(p, &names).iter().map(|x| x.rule).collect()
978 }
979
980 #[test]
981 fn proxy_pass_to_an_undefined_service_is_flagged_but_only_when_services_exist() {
982 let loc = |t: &str| {
983 chunk(
984 "loc",
985 "nginx_location",
986 vec![f("path", "/"), f("proxy_pass", t)],
987 )
988 };
989 let svc = chunk("web app", "docker_service", vec![f("image", "x")]);
990 assert_eq!(
991 rules(&project(vec![loc("http://api:8080"), svc.clone()]), &[]),
992 ["nginx-proxy-pass-unknown-service"]
993 );
994 assert!(rules(
996 &project(vec![loc("http://web_app:8080/x"), svc.clone()]),
997 &[]
998 )
999 .is_empty());
1000 assert!(rules(&project(vec![loc("http://api:8080")]), &[]).is_empty());
1002 for t in [
1004 "http://api.example.com",
1005 "http://10.0.0.5:80",
1006 "http://localhost:3000",
1007 "http://$backend",
1008 "http://unix:/run/x.sock",
1009 ] {
1010 assert!(
1011 rules(&project(vec![loc(t), svc.clone()]), &[]).is_empty(),
1012 "{t}"
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn an_upstream_defined_in_the_project_satisfies_proxy_pass() {
1019 let up = chunk(
1020 "backend",
1021 "nginx_upstream",
1022 vec![f("server", "10.0.0.1:80")],
1023 );
1024 let loc = chunk(
1025 "loc",
1026 "nginx_location",
1027 vec![f("proxy_pass", "http://backend")],
1028 );
1029 let svc = chunk("web", "docker_service", vec![]);
1030 assert!(rules(&project(vec![up, loc, svc]), &[]).is_empty());
1031 }
1032
1033 #[test]
1034 fn two_peers_with_the_same_network_are_an_error_but_nesting_is_fine() {
1035 let peer = |n: &str, ips: &str| chunk(n, "wg_peer", vec![f("AllowedIPs", ips)]);
1036 assert_eq!(
1037 rules(
1038 &project(vec![peer("a", "10.0.0.2/32"), peer("b", "10.0.0.2")]),
1039 &[]
1040 ),
1041 ["wireguard-allowed-ips-duplicate"]
1042 );
1043 assert_eq!(
1045 rules(
1046 &project(vec![peer("a", "10.0.0.5/24"), peer("b", "10.0.0.9/24")]),
1047 &[]
1048 )
1049 .len(),
1050 1
1051 );
1052 assert!(rules(
1054 &project(vec![peer("a", "0.0.0.0/0, ::/0"), peer("b", "10.0.0.2/32")]),
1055 &[]
1056 )
1057 .is_empty());
1058 assert!(rules(&project(vec![peer("a", "10.0.0.2/32, 10.0.0.2/32")]), &[]).is_empty());
1060 assert!(rules(&project(vec![peer("a", "${X}"), peer("b", "${X}")]), &[]).is_empty());
1061 }
1062
1063 #[test]
1064 fn a_disabled_peer_is_not_in_the_export_and_is_not_checked() {
1065 let mut b = chunk("b", "wg_peer", vec![f("AllowedIPs", "10.0.0.2/32")]);
1066 b["disabled"] = json!(true);
1067 let a = chunk("a", "wg_peer", vec![f("AllowedIPs", "10.0.0.2/32")]);
1068 assert!(rules(&project(vec![a, b]), &[]).is_empty());
1069 }
1070
1071 #[test]
1072 fn traefik_middleware_must_exist_unless_it_names_another_provider() {
1073 let router = |m: &str| chunk("r", "traefik_router", vec![f("middlewares", m)]);
1074 let mw = chunk("auth", "traefik_middleware", vec![f("type", "basicAuth")]);
1075 assert_eq!(
1076 rules(&project(vec![router("auth, gone"), mw.clone()]), &[]),
1077 ["traefik-middleware-missing"]
1078 );
1079 assert!(rules(&project(vec![router("AUTH"), mw.clone()]), &[]).is_empty());
1080 assert!(rules(&project(vec![router("sso@docker, api@internal")]), &[]).is_empty());
1081 }
1082
1083 #[test]
1084 fn an_ingress_needs_a_service_in_the_same_namespace_but_only_when_services_exist() {
1085 let svc = chunk(
1086 "s",
1087 "k8s_service",
1088 vec![f("name", "my-app"), f("namespace", "prod")],
1089 );
1090 let ing = |n: &str, ns: &str| {
1091 chunk(
1092 "i",
1093 "k8s_ingress",
1094 vec![f("serviceName", n), f("namespace", ns)],
1095 )
1096 };
1097 assert!(rules(&project(vec![svc.clone(), ing("my-app", "prod")]), &[]).is_empty());
1098 assert_eq!(
1099 rules(&project(vec![svc.clone(), ing("my-app", "default")]), &[]),
1100 ["k8s-ingress-service-missing"]
1101 );
1102 assert_eq!(
1103 rules(&project(vec![svc, ing("other", "prod")]), &[]),
1104 ["k8s-ingress-service-missing"]
1105 );
1106 assert!(rules(&project(vec![ing("anything", "prod")]), &[]).is_empty());
1107 }
1108
1109 #[test]
1110 fn a_compose_env_ref_must_resolve_to_a_vault_entry_or_an_env_file_key() {
1111 let env = |v: &str| json!({"key": "DB_PASS", "value": v, "field_type": "env_var", "description": "env"});
1112 let svc = |v: &str| chunk("web", "docker_service", vec![env(v)]);
1113 assert_eq!(
1114 rules(&project(vec![svc("${NOPE}")]), &["GitHub"]),
1115 ["compose-env-ref-unresolved"]
1116 );
1117 assert!(rules(&project(vec![svc("${GitHub/key}")]), &["GitHub"]).is_empty());
1118 assert!(rules(&project(vec![svc("${GITHUB_PROD}")]), &["GitHub"]).is_empty());
1119 let envf = chunk("e", "env_file", vec![f("NOPE", "1")]);
1120 assert!(rules(&project(vec![svc("${NOPE}"), envf]), &[]).is_empty());
1121 assert!(rules(&project(vec![svc("${chunk:x/y}")]), &[]).is_empty());
1122 assert!(rules(&project(vec![svc("plain")]), &[]).is_empty());
1123 }
1124
1125 #[test]
1126 fn a_pg_host_service_must_share_a_network_with_the_service_that_uses_it() {
1127 let pg = chunk("p", "pg_connection", vec![f("host", "db")]);
1128 let db = |nets: &str| chunk("db", "docker_service", vec![f("networks", nets)]);
1129 let app = |nets: &str| {
1130 chunk(
1131 "app",
1132 "docker_service",
1133 vec![f("networks", nets), f("depends_on", "db")],
1134 )
1135 };
1136 assert_eq!(
1137 rules(&project(vec![pg.clone(), db("back"), app("front")]), &[]),
1138 ["pg-host-network-unreachable"]
1139 );
1140 assert!(rules(
1141 &project(vec![pg.clone(), db("back"), app("front, back")]),
1142 &[]
1143 )
1144 .is_empty());
1145 assert!(rules(&project(vec![pg.clone(), db(""), app("")]), &[]).is_empty());
1147 let idle = chunk("idle", "docker_service", vec![f("networks", "front")]);
1149 assert!(rules(&project(vec![pg.clone(), db("back"), idle]), &[]).is_empty());
1150 let host_mode = chunk(
1152 "app",
1153 "docker_service",
1154 vec![f("network_mode", "host"), f("depends_on", "db")],
1155 );
1156 assert!(rules(&project(vec![pg, db("back"), host_mode]), &[]).is_empty());
1157 }
1158
1159 #[test]
1160 fn findings_carry_names_and_never_field_values_that_could_be_secret() {
1161 let pg = chunk(
1162 "p",
1163 "pg_connection",
1164 vec![
1165 f("host", "db"),
1166 json!({"key":"password","value":"hunter2-SECRET","field_type":"secret"}),
1167 ],
1168 );
1169 let db = chunk("db", "docker_service", vec![f("networks", "back")]);
1170 let app = chunk(
1171 "app",
1172 "docker_service",
1173 vec![f("networks", "front"), f("depends_on", "db")],
1174 );
1175 let out = check_project(&project(vec![pg, db, app]), &[]);
1176 let text =
1177 serde_json::to_string(&out.iter().map(Finding::to_json).collect::<Vec<_>>()).unwrap();
1178 assert!(!text.contains("hunter2"));
1179 }
1180
1181 #[test]
1182 fn nested_allowed_ips_are_a_warning_and_a_default_route_is_not_reported() {
1183 let peer = |n: &str, ips: &str| chunk(n, "wg_peer", vec![f("AllowedIPs", ips)]);
1184 assert_eq!(
1185 rules(
1186 &project(vec![peer("a", "10.0.0.0/24"), peer("b", "10.0.0.7/32")]),
1187 &[]
1188 ),
1189 ["wireguard-allowed-ips-overlap"]
1190 );
1191 assert!(rules(
1193 &project(vec![peer("a", "10.0.0.0/24"), peer("b", "10.0.1.7/32")]),
1194 &[]
1195 )
1196 .is_empty());
1197 assert!(rules(
1198 &project(vec![peer("a", "0.0.0.0/0"), peer("b", "10.0.0.7/32")]),
1199 &[]
1200 )
1201 .is_empty());
1202 assert!(rules(
1204 &project(vec![peer("a", "10.0.0.0/8"), peer("b", "fd00::1/128")]),
1205 &[]
1206 )
1207 .is_empty());
1208 assert_eq!(
1210 rules(
1211 &project(vec![peer("a", "fd00::/64"), peer("b", "fd00::5/128")]),
1212 &[]
1213 ),
1214 ["wireguard-allowed-ips-overlap"]
1215 );
1216 }
1217
1218 #[test]
1219 fn a_deployment_must_use_secrets_some_chunk_creates() {
1220 let secret = chunk(
1221 "app-secrets",
1222 "k8s_secret",
1223 vec![f("name", "app-secrets"), f("namespace", "prod")],
1224 );
1225 let dep = |env: &str, mounts: &str, ns: &str| {
1226 chunk(
1227 "web",
1228 "k8s_deployment",
1229 vec![
1230 f("secretEnv", env),
1231 f("secretMounts", mounts),
1232 f("namespace", ns),
1233 ],
1234 )
1235 };
1236 assert!(rules(
1237 &project(vec![
1238 secret.clone(),
1239 dep("app-secrets", "app-secrets:/etc/a", "prod")
1240 ]),
1241 &[]
1242 )
1243 .is_empty());
1244 assert_eq!(
1245 rules(&project(vec![secret.clone(), dep("gone", "", "prod")]), &[]),
1246 ["k8s-deployment-secret-missing"]
1247 );
1248 assert_eq!(
1249 rules(
1250 &project(vec![secret.clone(), dep("", "gone:/x", "prod")]),
1251 &[]
1252 ),
1253 ["k8s-deployment-secret-missing"]
1254 );
1255 assert_eq!(
1257 rules(
1258 &project(vec![secret.clone(), dep("app-secrets", "", "default")]),
1259 &[]
1260 ),
1261 ["k8s-deployment-secret-missing"]
1262 );
1263 assert_eq!(
1265 rules(
1266 &project(vec![
1267 secret.clone(),
1268 dep("gone gone", "gone:/x, nopath:", "prod")
1269 ]),
1270 &[]
1271 )
1272 .len(),
1273 1
1274 );
1275 assert!(rules(&project(vec![dep("gone", "gone:/x", "prod")]), &[]).is_empty());
1277 }
1278
1279 #[test]
1280 fn compose_substitution_tokens_anywhere_in_a_service_must_resolve() {
1281 let svc = |fields: Vec<Value>| chunk("web", "docker_service", fields);
1282 assert_eq!(
1284 rules(&project(vec![svc(vec![f("image", "app:${TAG}")])]), &[]),
1285 ["compose-env-ref-unresolved"]
1286 );
1287 let envf = chunk("e", "env_file", vec![f("tag", "1")]);
1289 assert!(rules(
1290 &project(vec![svc(vec![f("image", "app:${TAG}")]), envf]),
1291 &[]
1292 )
1293 .is_empty());
1294 for v in [
1296 "app:${TAG:-latest}",
1297 "app:${TAG-latest}",
1298 "$${TAG}",
1299 "app:${TAG:?need it}",
1300 "a:${TAG+x}",
1301 ] {
1302 assert!(
1303 rules(&project(vec![svc(vec![f("image", v)])]), &[]).is_empty(),
1304 "{v}"
1305 );
1306 }
1307 assert_eq!(
1309 rules(
1310 &project(vec![svc(vec![f("command", "run ${A} ${B} ${A}")])]),
1311 &[]
1312 )
1313 .len(),
1314 2
1315 );
1316 }
1317
1318 #[test]
1319 fn a_service_that_reads_the_pg_chunk_by_reference_is_a_consumer() {
1320 let pg = chunk("primary", "pg_connection", vec![f("host", "db")]);
1321 let db = chunk("db", "docker_service", vec![f("networks", "back")]);
1322 let env = json!({"key":"DB","value":"${chunk:primary/host}","field_type":"env_var","description":"env"});
1323 let app = chunk("app", "docker_service", vec![f("networks", "front"), env]);
1324 let got = rules(&project(vec![pg, db, app]), &[]);
1325 assert!(got.contains(&"pg-host-network-unreachable"), "{got:?}");
1326 }
1327 #[test]
1330 fn traefik_service_missing_fires_and_stays_silent() {
1331 let router = |svc: &str| {
1332 chunk(
1333 "r",
1334 "traefik_router",
1335 vec![f("rule", "Host(`a`)"), f("service", svc)],
1336 )
1337 };
1338 let svc = chunk("api", "traefik_service", vec![f("url", "http://x")]);
1339 assert_eq!(
1340 rules(&project(vec![router("web"), svc.clone()]), &[]),
1341 ["traefik-service-missing"]
1342 );
1343 assert!(rules(&project(vec![router("API"), svc.clone()]), &[]).is_empty());
1344 assert!(rules(&project(vec![router("web@docker"), svc.clone()]), &[]).is_empty());
1345 assert!(rules(&project(vec![router("${svc}"), svc]), &[]).is_empty());
1346 assert!(rules(&project(vec![router("web")]), &[]).is_empty());
1348 }
1349
1350 #[test]
1351 fn an_unused_upstream_fires_only_when_proxy_passes_can_be_read() {
1352 let up = chunk("backend", "nginx_upstream", vec![f("server", "x:1")]);
1353 let loc = |t: &str| {
1354 chunk(
1355 "l",
1356 "nginx_location",
1357 vec![f("path", "/"), f("proxy_pass", t)],
1358 )
1359 };
1360 assert_eq!(
1361 rules(&project(vec![up.clone(), loc("http://other")]), &[]),
1362 ["nginx-upstream-unused"]
1363 );
1364 assert!(rules(&project(vec![up.clone(), loc("http://backend/")]), &[]).is_empty());
1365 assert!(rules(&project(vec![up.clone(), loc("${x}")]), &[]).is_empty());
1367 assert!(rules(&project(vec![up]), &[]).is_empty());
1369 }
1370
1371 #[test]
1372 fn depends_on_an_unknown_service_is_an_error_but_not_without_services_or_for_refs() {
1373 let svc = |n: &str, dep: &str| {
1374 chunk(
1375 n,
1376 "docker_service",
1377 vec![f("image", "x"), f("depends_on", dep)],
1378 )
1379 };
1380 let db = chunk("db", "docker_service", vec![f("image", "pg")]);
1381 assert_eq!(
1382 rules(&project(vec![svc("web", "db, cache"), db.clone()]), &[]),
1383 ["compose-depends-on-unknown-service"]
1384 );
1385 assert!(rules(&project(vec![svc("web", "DB"), db.clone()]), &[]).is_empty());
1386 assert!(rules(&project(vec![svc("web", "${deps}"), db]), &["deps"]).is_empty());
1387 }
1388
1389 #[test]
1390 fn a_published_port_two_services_bind_is_an_error() {
1391 let svc =
1392 |n: &str, p: &str| chunk(n, "docker_service", vec![f("image", "x"), f("ports", p)]);
1393 assert_eq!(
1394 rules(
1395 &project(vec![svc("a", "8080:80"), svc("b", "8080:3000")]),
1396 &[]
1397 ),
1398 ["compose-port-clash"]
1399 );
1400 assert!(rules(
1402 &project(vec![svc("a", "8080:80"), svc("b", "8080:80/udp")]),
1403 &[]
1404 )
1405 .is_empty());
1406 assert!(rules(
1407 &project(vec![
1408 svc("a", "127.0.0.1:8080:80"),
1409 svc("b", "127.0.0.2:8080:80")
1410 ]),
1411 &[]
1412 )
1413 .is_empty());
1414 assert_eq!(
1415 rules(
1416 &project(vec![
1417 svc("a", "127.0.0.1:8080:80"),
1418 svc("b", "0.0.0.0:8080:80")
1419 ]),
1420 &[]
1421 ),
1422 ["compose-port-clash"]
1423 );
1424 assert!(rules(&project(vec![svc("a", "80"), svc("b", "80")]), &[]).is_empty());
1425 assert!(rules(
1426 &project(vec![svc("a", "8000-8010:80"), svc("b", "8000-8010:80")]),
1427 &[]
1428 )
1429 .is_empty());
1430 assert!(rules(&project(vec![svc("a", "8080:80, 9090:80")]), &[]).is_empty());
1431 }
1432
1433 #[test]
1434 fn a_service_network_must_be_declared_once_networks_are_managed_here() {
1435 let svc = |n: &str| chunk("web", "docker_service", vec![f("networks", n)]);
1436 let net = chunk("n", "docker_network", vec![f("backend", "bridge")]);
1437 assert!(rules(&project(vec![svc("backend"), net.clone()]), &[]).is_empty());
1438 assert!(rules(&project(vec![svc("default, backend"), net.clone()]), &[]).is_empty());
1439 assert_eq!(
1440 rules(&project(vec![svc("backend, other"), net]), &[]),
1441 ["compose-network-undeclared"]
1442 );
1443 assert!(rules(&project(vec![svc("anything")]), &[]).is_empty());
1444 }
1445
1446 #[test]
1447 fn a_k8s_service_needs_the_deployment_its_selector_names() {
1448 let dep = chunk(
1449 "d",
1450 "k8s_deployment",
1451 vec![f("name", "app"), f("namespace", "prod")],
1452 );
1453 let svc =
1454 |n: &str, ns: &str| chunk("s", "k8s_service", vec![f("name", n), f("namespace", ns)]);
1455 assert!(rules(&project(vec![dep.clone(), svc("app", "prod")]), &[]).is_empty());
1456 assert_eq!(
1457 rules(&project(vec![dep.clone(), svc("app", "dev")]), &[]),
1458 ["k8s-service-selector-unmatched"]
1459 );
1460 assert_eq!(
1461 rules(&project(vec![dep, svc("web", "prod")]), &[]),
1462 ["k8s-service-selector-unmatched"]
1463 );
1464 assert!(rules(&project(vec![svc("web", "prod")]), &[]).is_empty());
1465 }
1466
1467 #[test]
1468 fn a_proxy_pass_host_may_be_a_service_of_another_project_only_in_wide_scope() {
1469 let web = project(vec![chunk(
1470 "n",
1471 "nginx_location",
1472 vec![f("proxy_pass", "http://api:8080")],
1473 )]);
1474 let own = chunk("web", "docker_service", vec![]);
1475 let other = project(vec![chunk("api", "docker_service", vec![])]);
1476 let alone = project(vec![
1478 chunk(
1479 "n",
1480 "nginx_location",
1481 vec![f("proxy_pass", "http://api:8080")],
1482 ),
1483 own,
1484 ]);
1485 assert_eq!(
1486 check_project(&alone, &[])
1487 .iter()
1488 .map(|x| x.rule)
1489 .collect::<Vec<_>>(),
1490 ["nginx-proxy-pass-unknown-service"]
1491 );
1492 let els = services_of(&[other]);
1493 assert_eq!(els, ["api"]);
1494 assert!(check_project_scoped(&alone, &[], &els).is_empty());
1495 assert!(check_project_scoped(&web, &[], &els).is_empty());
1497 assert!(
1498 check_project(&web, &[]).is_empty(),
1499 "no evidence, no finding"
1500 );
1501 let missing = project(vec![chunk(
1503 "n",
1504 "nginx_location",
1505 vec![f("proxy_pass", "http://ghost:1")],
1506 )]);
1507 assert_eq!(check_project_scoped(&missing, &[], &els).len(), 1);
1508 }
1509
1510 #[test]
1511 fn the_gate_lets_warnings_through_and_stops_errors() {
1512 let net = chunk("web", "docker_service", vec![f("networks", "nope")]);
1513 let decl = chunk("n", "docker_network", vec![f("backend", "bridge")]);
1514 assert_eq!(gate(&project(vec![net, decl]), &[]).len(), 1);
1515 let warn = project(vec![
1516 chunk(
1517 "n",
1518 "nginx_location",
1519 vec![f("proxy_pass", "http://ghost:1")],
1520 ),
1521 chunk("web", "docker_service", vec![]),
1522 ]);
1523 assert_eq!(check_project(&warn, &[]).len(), 1);
1524 assert!(gate(&warn, &[]).is_empty());
1525 }
1526}