1use std::collections::{HashMap, HashSet};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use tabled::{
6 settings::{locator::ByColumnName, Disable, Margin, Style},
7 Table, Tabled,
8};
9
10use crate::{
11 finding::{self, Findings},
12 k8s::resources::{self, Resource},
13 version,
14};
15
16#[derive(Clone, Debug, Serialize, Deserialize, Tabled)]
20#[tabled(rename_all = "UpperCase")]
21pub struct VersionSkew {
22 #[tabled(inline)]
23 pub finding: finding::Finding,
24 pub name: String,
25 #[tabled(skip)]
26 pub kubelet_version: String,
27 #[tabled(rename = "NODE")]
28 pub kubernetes_version: String,
29 #[tabled(rename = "CONTROL PLANE")]
30 pub control_plane_version: String,
31 #[tabled(rename = "SKEW")]
32 pub version_skew: String,
33}
34
35#[derive(Debug, Serialize, Deserialize, Tabled)]
36#[tabled(rename_all = "UpperCase")]
37pub struct VersionSkewSummary {
38 #[tabled(inline)]
39 pub version_skew: VersionSkew,
40 pub quantity: i32,
41}
42
43impl Findings for Vec<VersionSkew> {
44 fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
45 if self.is_empty() {
46 return Ok(format!(
47 "{leading_whitespace}✅ - No reported findings regarding version skew between the control plane and nodes"
48 ));
49 }
50
51 let mut summary: HashMap<(String, String, String, String, String), VersionSkewSummary> = HashMap::new();
52 for node in self {
53 let key = (
54 node.finding.code.to_string(),
55 node.finding.symbol.to_owned(),
56 node.finding.remediation.to_string(),
57 node.kubernetes_version.to_owned(),
58 node.control_plane_version.to_owned(),
59 );
60
61 if let Some(summary) = summary.get_mut(&key) {
62 summary.quantity += 1;
63 } else {
64 summary.insert(
65 key,
66 VersionSkewSummary {
67 version_skew: node.clone(),
68 quantity: 1,
69 },
70 );
71 }
72 }
73
74 let mut summary_tbl = Table::new(summary);
75 summary_tbl
76 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
77 .with(Disable::column(ByColumnName::new("String")))
78 .with(Disable::column(ByColumnName::new("NAME")))
79 .with(Style::markdown());
80
81 let mut table = Table::new(self);
82 table
83 .with(Disable::column(ByColumnName::new("CHECK")))
84 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
85 .with(Style::markdown());
86
87 Ok(format!("{summary_tbl}\n\n{table}\n"))
88 }
89
90 fn to_stdout_table(&self) -> Result<String> {
91 if self.is_empty() {
92 return Ok("".to_owned());
93 }
94
95 let mut table = Table::new(self);
96 table.with(Style::sharp());
97
98 Ok(format!("{table}\n"))
99 }
100}
101
102pub async fn version_skew(nodes: &[resources::Node], cluster_version: &str) -> Result<Vec<VersionSkew>> {
104 let mut findings = vec![];
105
106 for node in nodes {
107 let control_plane_minor_version = version::parse_minor(cluster_version)?;
108 let version_skew = control_plane_minor_version - node.minor_version;
109 if version_skew == 0 {
110 continue;
111 }
112
113 let mut remediation = match version_skew {
117 1 => finding::Remediation::Recommended,
118 _ => finding::Remediation::Required,
119 };
120
121 if let Some(labels) = &node.labels {
122 if labels.contains_key("eks.amazonaws.com/nodegroup") {
123 remediation = finding::Remediation::Required;
126 }
127 }
128
129 if node.name.starts_with("fargate-") {
130 remediation = finding::Remediation::Required;
133 }
134
135 let finding = finding::Finding {
136 code: finding::Code::K8S001,
137 symbol: remediation.symbol(),
138 remediation,
139 };
140
141 let node = VersionSkew {
142 finding,
143 name: node.name.to_owned(),
144 kubelet_version: node.kubelet_version.to_owned(),
145 kubernetes_version: format!("v{}", version::normalize(&node.kubelet_version).unwrap()),
146 control_plane_version: format!("v{cluster_version}"),
147 version_skew: format!("+{version_skew}"),
148 };
149
150 findings.push(node)
151 }
152
153 Ok(findings)
154}
155
156#[derive(Debug, Serialize, Deserialize, Tabled)]
157#[tabled(rename_all = "UpperCase")]
158pub struct MinReplicas {
159 #[tabled(inline)]
160 pub finding: finding::Finding,
161 #[tabled(inline)]
162 pub resource: Resource,
163 pub replicas: i32,
165}
166
167impl Findings for Vec<MinReplicas> {
168 fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
169 if self.is_empty() {
170 return Ok(format!(
171 "{leading_whitespace}✅ - All relevant Kubernetes workloads have at least 3 replicas specified"
172 ));
173 }
174
175 let mut table = Table::new(self);
176 table
177 .with(Disable::column(ByColumnName::new("CHECK")))
178 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
179 .with(Style::markdown());
180
181 Ok(format!("{table}\n"))
182 }
183
184 fn to_stdout_table(&self) -> Result<String> {
185 if self.is_empty() {
186 return Ok("".to_owned());
187 }
188
189 let mut table = Table::new(self);
190 table.with(Style::sharp());
191
192 Ok(format!("{table}\n"))
193 }
194}
195
196#[derive(Debug, Serialize, Deserialize, Tabled)]
197#[tabled(rename_all = "UpperCase")]
198pub struct MinReadySeconds {
199 #[tabled(inline)]
200 pub finding: finding::Finding,
201 #[tabled(inline)]
202 pub resource: Resource,
203 pub seconds: i32,
205}
206
207impl Findings for Vec<MinReadySeconds> {
208 fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
209 if self.is_empty() {
210 return Ok(format!(
211 "{leading_whitespace}✅ - All relevant Kubernetes workloads minReadySeconds set to more than 0"
212 ));
213 }
214
215 let mut table = Table::new(self);
216 table
217 .with(Disable::column(ByColumnName::new("CHECK")))
218 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
219 .with(Style::markdown());
220
221 Ok(format!("{table}\n"))
222 }
223
224 fn to_stdout_table(&self) -> Result<String> {
225 if self.is_empty() {
226 return Ok("".to_owned());
227 }
228
229 let mut table = Table::new(self);
230 table.with(Style::sharp());
231
232 Ok(format!("{table}\n"))
233 }
234}
235
236#[derive(Debug, Serialize, Deserialize, Tabled)]
237#[tabled(rename_all = "UpperCase")]
238pub struct PodDisruptionBudget {
239 #[tabled(inline)]
240 pub finding: finding::Finding,
241 #[tabled(inline)]
242 pub resource: Resource,
243 }
246
247#[derive(Debug, Serialize, Deserialize, Tabled)]
248#[tabled(rename_all = "UpperCase")]
249pub struct PodTopologyDistribution {
250 #[tabled(inline)]
251 pub finding: finding::Finding,
252 #[tabled(inline)]
253 pub resource: Resource,
254
255 pub anti_affinity: bool,
256 pub topology_spread_constraints: bool,
257}
258
259impl Findings for Vec<PodTopologyDistribution> {
260 fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
261 if self.is_empty() {
262 return Ok(format!(
263 "{leading_whitespace}✅ - All relevant Kubernetes workloads have either podAntiAffinity or topologySpreadConstraints set"
264 ));
265 }
266
267 let mut table = Table::new(self);
268 table
269 .with(Disable::column(ByColumnName::new("CHECK")))
270 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
271 .with(Style::markdown());
272
273 Ok(format!("{table}\n"))
274 }
275
276 fn to_stdout_table(&self) -> Result<String> {
277 if self.is_empty() {
278 return Ok("".to_owned());
279 }
280
281 let mut table = Table::new(self);
282 table.with(Style::sharp());
283
284 Ok(format!("{table}\n"))
285 }
286}
287
288#[derive(Debug, Serialize, Deserialize, Tabled)]
289#[tabled(rename_all = "UpperCase")]
290pub struct Probe {
291 #[tabled(inline)]
292 pub finding: finding::Finding,
293
294 #[tabled(inline)]
295 pub resource: Resource,
296 #[tabled(rename = "READINESS PROBE")]
297 pub readiness_probe: bool,
298}
299
300impl Findings for Vec<Probe> {
301 fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
302 if self.is_empty() {
303 return Ok(format!(
304 "{leading_whitespace}✅ - All relevant Kubernetes workloads have a readiness probe configured"
305 ));
306 }
307
308 let mut table = Table::new(self);
309 table
310 .with(Disable::column(ByColumnName::new("CHECK")))
311 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
312 .with(Style::markdown());
313
314 Ok(format!("{table}\n"))
315 }
316
317 fn to_stdout_table(&self) -> Result<String> {
318 if self.is_empty() {
319 return Ok("".to_owned());
320 }
321
322 let mut table = Table::new(self);
323 table.with(Style::sharp());
324
325 Ok(format!("{table}\n"))
326 }
327}
328
329#[derive(Debug, Serialize, Deserialize, Tabled)]
330#[tabled(rename_all = "UpperCase")]
331pub struct TerminationGracePeriod {
332 #[tabled(inline)]
333 pub finding: finding::Finding,
334
335 #[tabled(inline)]
336 pub resource: Resource,
337 pub termination_grace_period: i64,
339}
340
341impl Findings for Vec<TerminationGracePeriod> {
342 fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
343 if self.is_empty() {
344 return Ok(format!(
345 "{leading_whitespace}✅ - No StatefulSet workloads have a terminationGracePeriodSeconds set to more than 0"
346 ));
347 }
348
349 let mut table = Table::new(self);
350 table
351 .with(Disable::column(ByColumnName::new("CHECK")))
352 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
353 .with(Style::markdown());
354
355 Ok(format!("{table}\n"))
356 }
357
358 fn to_stdout_table(&self) -> Result<String> {
359 if self.is_empty() {
360 return Ok("".to_owned());
361 }
362
363 let mut table = Table::new(self);
364 table.with(Style::sharp());
365
366 Ok(format!("{table}\n"))
367 }
368}
369
370#[derive(Debug, Serialize, Deserialize, Tabled)]
371#[tabled(rename_all = "UpperCase")]
372pub struct DockerSocket {
373 #[tabled(inline)]
374 pub finding: finding::Finding,
375
376 #[tabled(inline)]
377 pub resource: Resource,
378
379 pub docker_socket: bool,
380}
381
382impl Findings for Vec<DockerSocket> {
383 fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
384 if self.is_empty() {
385 return Ok(format!(
386 "{leading_whitespace}✅ - No relevant Kubernetes workloads are found to be utilizing the Docker socket"
387 ));
388 }
389
390 let mut table = Table::new(self);
391 table
392 .with(Disable::column(ByColumnName::new("CHECK")))
393 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
394 .with(Style::markdown());
395
396 Ok(format!("{table}\n"))
397 }
398
399 fn to_stdout_table(&self) -> Result<String> {
400 if self.is_empty() {
401 return Ok("".to_owned());
402 }
403
404 let mut table = Table::new(self);
405 table.with(Style::sharp());
406
407 Ok(format!("{table}\n"))
408 }
409}
410
411#[derive(Debug, Serialize, Deserialize, Tabled)]
412#[tabled(rename_all = "UpperCase")]
413pub struct PodSecurityPolicy {
414 #[tabled(inline)]
415 pub finding: finding::Finding,
416
417 #[tabled(inline)]
418 pub resource: Resource,
419}
420
421impl Findings for Vec<PodSecurityPolicy> {
422 fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
423 if self.is_empty() {
424 return Ok(format!(
425 "{leading_whitespace}✅ - No PodSecurityPolicys were found within the cluster"
426 ));
427 }
428
429 let mut table = Table::new(self);
430 table
431 .with(Disable::column(ByColumnName::new("CHECK")))
432 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
433 .with(Style::markdown());
434
435 Ok(format!("{table}\n"))
436 }
437
438 fn to_stdout_table(&self) -> Result<String> {
439 if self.is_empty() {
440 return Ok("".to_owned());
441 }
442
443 let mut table = Table::new(self);
444 table.with(Style::sharp());
445
446 Ok(format!("{table}\n"))
447 }
448}
449
450#[derive(Clone, Debug, Serialize, Deserialize, Tabled)]
451#[tabled(rename_all = "UpperCase")]
452pub struct KubeProxyVersionSkew {
453 #[tabled(inline)]
454 pub finding: finding::Finding,
455 #[tabled(rename = "KUBELET")]
456 pub kubelet_version: String,
457 #[tabled(rename = "KUBE PROXY")]
458 pub kube_proxy_version: String,
459 #[tabled(rename = "SKEW")]
460 pub version_skew: String,
461}
462
463pub async fn kube_proxy_version_skew(
464 nodes: &[resources::Node],
465 resources: &[resources::StdResource],
466) -> Result<Vec<KubeProxyVersionSkew>> {
467 let kube_proxy = match resources
468 .iter()
469 .filter(|r| r.metadata.kind == resources::Kind::DaemonSet && r.metadata.name == "kube-proxy")
470 .collect::<Vec<_>>()
471 .first()
472 {
473 Some(k) => k.to_owned(),
474 None => {
475 println!("Unable to find kube-proxy");
476 return Ok(vec![]);
477 }
478 };
479
480 let ptmpl = kube_proxy.spec.template.as_ref().unwrap();
481 let pspec = ptmpl.spec.as_ref().unwrap();
482 let kproxy_minor_version = pspec
483 .containers
484 .iter()
485 .map(|container| {
486 let image = container.image.as_ref().unwrap().split(':').collect::<Vec<_>>()[1];
488 version::parse_minor(image).unwrap()
489 })
490 .next()
491 .context("Unable to find image version for kube-proxy")?;
492
493 let findings = nodes
494 .iter()
495 .map(|node| node.minor_version)
496 .collect::<HashSet<_>>()
497 .into_iter()
498 .filter(|node_ver| node_ver != &kproxy_minor_version)
499 .map(|node_ver| {
500 let remediation = finding::Remediation::Required;
501 let finding = finding::Finding {
502 code: finding::Code::K8S011,
503 symbol: remediation.symbol(),
504 remediation,
505 };
506
507 KubeProxyVersionSkew {
508 finding,
509 kubelet_version: format!("v1.{node_ver}"),
510 kube_proxy_version: format!("v1.{kproxy_minor_version}"),
511 version_skew: format!("{}", kproxy_minor_version - node_ver),
512 }
513 })
514 .collect();
515
516 Ok(findings)
517}
518
519impl Findings for Vec<KubeProxyVersionSkew> {
520 fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
521 if self.is_empty() {
522 return Ok(format!(
523 "{leading_whitespace}✅ - `kube-proxy` version is aligned with the node/`kubelet` versions in use"
524 ));
525 }
526
527 let mut table = Table::new(self);
528 table
529 .with(Disable::column(ByColumnName::new("CHECK")))
530 .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
531 .with(Style::markdown());
532
533 Ok(format!("{table}\n"))
534 }
535
536 fn to_stdout_table(&self) -> Result<String> {
537 if self.is_empty() {
538 return Ok("".to_owned());
539 }
540
541 let mut table = Table::new(self);
542 table.with(Style::sharp());
543
544 Ok(format!("{table}\n"))
545 }
546}
547
548pub trait K8sFindings {
549 fn get_resource(&self) -> Resource;
550
551 fn min_replicas(&self) -> Option<MinReplicas>;
553
554 fn min_ready_seconds(&self) -> Option<MinReadySeconds>;
556
557 fn pod_topology_distribution(&self) -> Option<PodTopologyDistribution>;
562
563 fn readiness_probe(&self) -> Option<Probe>;
565
566 fn termination_grace_period(&self) -> Option<TerminationGracePeriod>;
568
569 fn docker_socket(&self, target_version: &str) -> Option<DockerSocket>;
571
572 }