From 3e793b0acb1e341feec2952948e390794e450300 Mon Sep 17 00:00:00 2001 From: GeekMasher Date: Thu, 3 Aug 2023 18:21:17 +0100 Subject: [PATCH 1/2] feat: move rules and small updates to loading --- src/compose.rs | 3 +- src/compose/rules.rs | 474 --------------------------------------- src/main.rs | 6 +- src/rules.rs | 71 ++++++ src/rules/all.rs | 182 +++++++++++++++ src/rules/environment.rs | 103 +++++++++ src/rules/images.rs | 99 ++++++++ src/rules/registry.rs | 33 +++ src/rules/socket.rs | 44 ++++ src/rules/version.rs | 53 +++++ src/security.rs | 59 +---- 11 files changed, 591 insertions(+), 536 deletions(-) delete mode 100644 src/compose/rules.rs create mode 100644 src/rules.rs create mode 100644 src/rules/all.rs create mode 100644 src/rules/environment.rs create mode 100644 src/rules/images.rs create mode 100644 src/rules/registry.rs create mode 100644 src/rules/socket.rs create mode 100644 src/rules/version.rs diff --git a/src/compose.rs b/src/compose.rs index 08296b5..6998738 100644 --- a/src/compose.rs +++ b/src/compose.rs @@ -8,10 +8,9 @@ use std::{ path::{Path, PathBuf}, }; -pub mod rules; pub mod spec; -pub use rules::*; +pub use crate::rules::*; pub use spec::*; use walkdir::WalkDir; diff --git a/src/compose/rules.rs b/src/compose/rules.rs deleted file mode 100644 index f4a8240..0000000 --- a/src/compose/rules.rs +++ /dev/null @@ -1,474 +0,0 @@ -use anyhow::Result; -use log::debug; - -use crate::{ - compose::{ComposeFile, ListOrHashMap, StringOrBuild, StringOrNumber}, - config::Config, - security::{Alert, AlertLocation, RuleID, Severity}, -}; - -/// Check which compose spec version is being used -pub fn docker_version( - _config: &Config, - compose_file: &ComposeFile, - alerts: &mut Vec, -) -> Result<()> { - debug!("Compose Version rule enabled..."); - // https://docs.docker.com/compose/compose-file/compose-versioning/ - if let Some(version) = &compose_file.compose.version { - match version.as_str() { - "1" => alerts.push(Alert { - id: RuleID::Quibble("COMPOSE_V1".to_string()), - details: String::from("Compose v1"), - severity: Severity::Medium, - path: AlertLocation { - path: compose_file.path.clone(), - line: compose_file.mappings.get("version").copied(), - }, - }), - "2" | "2.0" | "2.1" | "2.2" | "2.3" | "2.4" => alerts.push(Alert { - id: RuleID::Quibble("COMPOSE_V2".to_string()), - details: String::from("Compose v2 used"), - severity: Severity::Low, - path: AlertLocation { - path: compose_file.path.clone(), - line: compose_file.mappings.get("version").copied(), - }, - }), - "3" | "3.0" | "3.1" | "3.2" | "3.3" | "3.4" | "3.5" => alerts.push(Alert { - id: RuleID::Quibble("COMPOSE_V3".to_string()), - details: String::from("Using old Compose v3 spec, consider upgrading"), - severity: Severity::Low, - path: AlertLocation { - path: compose_file.path.clone(), - line: compose_file.mappings.get("version").copied(), - }, - }), - _ => { - debug!("Unknown or secure version of Docker Compose") - } - } - } - Ok(()) -} - -/// Container Images -pub fn container_images( - _config: &Config, - compose_file: &ComposeFile, - alerts: &mut Vec, -) -> Result<()> { - for (name, service) in &compose_file.compose.services { - // Manually building project - if let Some(build_enum) = &service.build { - let mapping_line = compose_file - .mappings - .get(format!("services.{}.build", name).as_str()); - - match build_enum { - StringOrBuild::Str(context) => alerts.push(Alert { - id: RuleID::Quibble("BUILD_CONTEXT".to_string()), - details: format!("Build context path: {context}"), - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping_line.copied(), - }, - ..Default::default() - }), - StringOrBuild::Build(build) => { - if let Some(context) = &build.context { - alerts.push(Alert { - id: RuleID::Quibble("BUILD_CONTEXT".to_string()), - details: format!("Build context path: {context}"), - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping_line.copied(), - }, - ..Default::default() - }) - } - } - } - } - - // Pulling remote image - if let Some(image) = &service.image { - let mapping_line = compose_file - .mappings - .get(format!("services.{}.image", name).as_str()); - - // Format strings - if image.contains("${") { - alerts.push(Alert { - id: RuleID::Quibble("IMAGE_ENV_VAR".to_string()), - details: format!("Container Image using Environment Variable: {image}"), - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping_line.copied(), - }, - ..Default::default() - }) - } else if let Ok(container) = service.parse_image() { - alerts.push(Alert { - id: RuleID::Quibble("IMAGE_TAG".to_string()), - details: format!("Container Image: {container}"), - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping_line.copied(), - }, - ..Default::default() - }); - - // Rule: Pinned to latest rolling container image - // - The main reason behind this is if you are using watchtower or other - // service to update containers it might cause issues - let latest = vec!["latest", "main", "master"]; - if latest.contains(&container.tag.as_str()) { - alerts.push(Alert { - id: RuleID::Quibble("IMAGE_TAG_LATEST".to_string()), - details: format!( - "Container using rolling release tag: `{}`", - container.tag - ), - severity: Severity::Medium, - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping_line.copied(), - }, - ..Default::default() - }); - } - } - } - } - Ok(()) -} - -pub fn privileged( - _config: &Config, - compose_file: &ComposeFile, - alerts: &mut Vec, -) -> Result<()> { - for service in compose_file.compose.services.values() { - if let Some(privilege) = &service.privileged { - if *privilege { - alerts.push(Alert { - id: RuleID::Quibble("PRIVILEGED_CONTAINER".to_string()), - details: String::from("Container privilege enabled"), - severity: Severity::High, - path: AlertLocation { - path: compose_file.path.clone(), - ..Default::default() - }, - ..Default::default() - }) - } - } - } - Ok(()) -} - -/// Docker registry Rule -/// -/// Make sure that the container is being pulled from a trusted source -pub fn docker_registry( - config: &Config, - compose_file: &ComposeFile, - alerts: &mut Vec, -) -> Result<()> { - for service in compose_file.compose.services.values() { - if let Ok(container) = service.parse_image() { - if !config.registries.contains(&container.instance) { - alerts.push(Alert { - id: RuleID::Quibble("DOCKER_REGISTRY".to_string()), - details: format!("Container from unknown registry: {}", &container.instance), - severity: Severity::High, - path: AlertLocation { - path: compose_file.path.clone(), - ..Default::default() - }, - ..Default::default() - }); - } - } - } - Ok(()) -} - -/// Docker Socket Rule -pub fn docker_socket( - _config: &Config, - compose_file: &ComposeFile, - alerts: &mut Vec, -) -> Result<()> { - debug!("Docker Socker Rule enabled..."); - - for (name, service) in &compose_file.compose.services { - if let Some(volumes) = &service.volumes { - let result = volumes - .iter() - .find(|&s| s.starts_with("/var/run/docker.sock")); - - if result.is_some() { - let mapping_line = compose_file - .mappings - .get(format!("services.{}.volumes", name).as_str()); - - alerts.push(Alert { - id: RuleID::Quibble("DOCKER_SOCKET".to_string()), - details: String::from("Docker Socket being passed into container"), - severity: Severity::High, - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping_line.copied(), - }, - }) - } - } - } - Ok(()) -} - -/// Security Opts Rule -pub fn security_opts( - _config: &Config, - compose_file: &ComposeFile, - alerts: &mut Vec, -) -> Result<()> { - for (name, service) in &compose_file.compose.services { - if let Some(secopts) = &service.security_opt { - let mapping = compose_file - .mappings - .get(format!("services.{}.security_opt", name).as_str()); - - for secopt in secopts { - if secopt.starts_with("no-new-privileges") && secopt.ends_with("false") { - alerts.push(Alert { - id: RuleID::Quibble("SECURITY_OPTS".to_string()), - details: format!( - "Security Opts `no-new-privileges` set to `false` for '{service}'" - ), - severity: Severity::High, - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping.copied(), - }, - }) - } - } - } else { - let mapping = compose_file - .mappings - .get(format!("services.{}", name).as_str()); - - alerts.push(Alert { - id: RuleID::Quibble("SECURITY_OPTS".to_string()), - details: format!("Security Opts `no-new-privileges` not set for '{service}'"), - severity: Severity::High, - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping.copied(), - }, - }) - } - } - Ok(()) -} - -pub fn kernel_parameters( - _config: &Config, - compose_file: &ComposeFile, - alerts: &mut Vec, -) -> Result<()> { - for service in compose_file.compose.services.values() { - if let Some(syscalls) = &service.sysctls { - alerts.push(Alert { - id: RuleID::Quibble("KERNEL_PARAMETERS".to_string()), - details: String::from("Enabling extra syscalls"), - ..Default::default() - }); - - fn syscall_check( - syscall: &String, - compose_file: &ComposeFile, - alerts: &mut Vec, - ) { - if syscall.starts_with("net.ipv4.conf.all") { - alerts.push(Alert { - id: RuleID::Quibble("KERNEL_PARAMETERS".to_string()), - details: format!("IPv4 Kernal Parameters modified: {syscall}"), - severity: Severity::Information, - path: AlertLocation { - path: compose_file.path.clone(), - ..Default::default() - }, - }) - } - } - - match syscalls { - ListOrHashMap::Vec(v) => { - for syscall in v { - match syscall { - StringOrNumber::Str(syscall) => { - syscall_check(syscall, compose_file, alerts); - } - _ => { - debug!("Unsupported syscall type: int / none") - } - } - } - } - ListOrHashMap::Hash(h) => { - for syscall in h.keys() { - syscall_check(syscall, compose_file, alerts); - } - } - } - } - - if let Some(capabilities) = &service.cap_add { - alerts.push(Alert { - id: RuleID::Quibble("KERNEL_PARAMETERS".to_string()), - details: String::from("Using extra Kernel Parameters"), - ..Default::default() - }); - - for cap in capabilities { - // https://man7.org/linux/man-pages/man7/capabilities.7.html - // https://cloud.redhat.com/blog/increasing-security-of-istio-deployments-by-removing-the-need-for-privileged-containers - if cap.contains("NET_ADMIN") { - alerts.push(Alert { - id: RuleID::Quibble("NET_ADMIN".to_string()), - details: String::from("Container with high networking privileages"), - severity: Severity::Medium, - path: AlertLocation { - path: compose_file.path.clone(), - ..Default::default() - }, - }) - } - - if cap.contains("SYS_ADMIN") { - alerts.push(Alert { - id: RuleID::Quibble("SYS_ADMIN".to_string()), - details: String::from("Container with high system privileages"), - severity: Severity::Medium, - path: AlertLocation { - path: compose_file.path.clone(), - ..Default::default() - }, - }) - } - - if cap.contains("ALL") { - alerts.push(Alert { - id: RuleID::Quibble("ALL".to_string()), - details: String::from("All capabilities are enabled"), - severity: Severity::High, - path: AlertLocation { - path: compose_file.path.clone(), - ..Default::default() - }, - ..Default::default() - }) - } - } - } - } - Ok(()) -} - -fn check_environment( - compose_file: &ComposeFile, - alerts: &mut Vec, - service_name: &String, - key: String, - _value: String, -) { - if key.contains("DEBUG") { - let mapping = compose_file - .mappings - .get(format!("services.{}.environment.{}", service_name, key).as_str()); - - alerts.push(Alert { - id: RuleID::Cwe(String::from("1244")), - details: String::from("Debugging enabled in the container"), - severity: Severity::Medium, - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping.copied(), - }, - }) - } - // TODO: better way of detecting this - if key.contains("PASSWORD") || key.contains("KEY") || key.contains("TOKEN") { - let mapping = compose_file - .mappings - .get(format!("services.{}.environment.{}", service_name, key).as_str()); - - alerts.push(Alert { - id: RuleID::Cwe(String::from("215")), - details: String::from("Possible Hardcoded password"), - severity: Severity::Low, - path: AlertLocation { - path: compose_file.path.clone(), - line: mapping.copied(), - }, - }) - } -} - -/// Environment Variable rules -pub fn environment_variables( - _config: &Config, - compose_file: &ComposeFile, - alerts: &mut Vec, -) -> Result<()> { - for (name, service) in &compose_file.compose.services { - if let Some(envvars) = &service.environment { - match envvars { - ListOrHashMap::Vec(envvec) => { - for envvar in envvec { - match envvar { - StringOrNumber::Str(str) => { - if let Some((key, value)) = str.split_once('=') { - check_environment( - compose_file, - alerts, - name, - key.to_string(), - value.to_string(), - ) - } - } - _ => { - debug!("Unsupported type check int / none: {}", compose_file) - } - } - } - } - ListOrHashMap::Hash(envhash) => { - // warn!("Unsupported feature: envvars HashMap for {}", compose_file); - for (key, value) in envhash { - match value { - StringOrNumber::Str(value) => { - check_environment( - compose_file, - alerts, - name, - key.to_string(), - value.to_string(), - ); - } - _ => { - debug!("Unsupported type check int / none: {}", compose_file) - } - } - } - } - } - } - } - Ok(()) -} diff --git a/src/main.rs b/src/main.rs index 712f77b..05c5db6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,13 +14,15 @@ mod compose; mod config; mod containers; mod formatters; +mod rules; mod security; use crate::{ cli::{ArgumentCommands, Arguments, AUTHOR, BANNER, VERSION_NUMBER}, config::Config, formatters::sarif::SarifFile, - security::{Alert, Rules, Severity}, + rules::Rules, + security::{Alert, Severity}, }; fn output_cli(_config: &Config, severity: Severity, results: Vec) -> Result { @@ -106,7 +108,7 @@ fn main() -> Result<()> { }; debug!("Severity set :: {severity}"); - let mut rules = Rules::new(&config); + let mut rules = Rules::new(config.clone()); debug!("Rule count: {}", rules.len()); // Run the list of rules over the Compose File diff --git a/src/rules.rs b/src/rules.rs new file mode 100644 index 0000000..7a9420c --- /dev/null +++ b/src/rules.rs @@ -0,0 +1,71 @@ +use crate::{compose::ComposeFile, config::Config, security::Alert}; + +pub mod all; +pub mod environment; +pub mod images; +pub mod registry; +pub mod socket; +pub mod version; + +use all::*; +use anyhow::Result; +use environment::*; +use images::*; +use log::error; +use registry::*; +use socket::*; +use version::*; + +pub type Rule = dyn Fn(&Config, &ComposeFile, &mut Vec) -> Result<()>; + +pub struct Rules { + config: Config, + rules: Vec>, +} + +impl Rules { + pub fn new(config: Config) -> Self { + let mut rules = Rules { + config, + rules: Vec::new(), + }; + + if !rules.config.disable_rules { + rules + .register(docker_version) + .register(docker_socket) + .register(docker_registry) + .register(container_images) + .register(kernel_parameters) + .register(security_opts) + .register(privileged) + .register(environment_variables); + } + + rules + } + + pub fn run(&mut self, compose_file: &ComposeFile) -> Vec { + let mut alerts: Vec = Vec::new(); + for rule in self.rules.iter() { + if let Err(err) = rule(&self.config, compose_file, &mut alerts) { + error!("Error during rule execution: {err:?}"); + } + } + // Sort by severity + alerts.sort_by(|a, b| a.severity.cmp(&b.severity)); + alerts + } + + pub fn register(&mut self, rule: R) -> &mut Self + where + R: Fn(&Config, &ComposeFile, &mut Vec) -> Result<()> + 'static, + { + self.rules.push(Box::new(rule)); + self + } + + pub fn len(&self) -> usize { + self.rules.len() + } +} diff --git a/src/rules/all.rs b/src/rules/all.rs new file mode 100644 index 0000000..be2f4a2 --- /dev/null +++ b/src/rules/all.rs @@ -0,0 +1,182 @@ +use anyhow::Result; +use log::debug; + +use crate::{ + compose::{ComposeFile, ListOrHashMap, StringOrNumber}, + config::Config, + security::{Alert, AlertLocation, RuleID, Severity}, +}; + +pub fn privileged( + _config: &Config, + compose_file: &ComposeFile, + alerts: &mut Vec, +) -> Result<()> { + for service in compose_file.compose.services.values() { + if let Some(privilege) = &service.privileged { + if *privilege { + alerts.push(Alert { + id: RuleID::Quibble("PRIVILEGED_CONTAINER".to_string()), + details: String::from("Container privilege enabled"), + severity: Severity::High, + path: AlertLocation { + path: compose_file.path.clone(), + ..Default::default() + }, + ..Default::default() + }) + } + } + } + Ok(()) +} + +/// Security Opts Rule +pub fn security_opts( + _config: &Config, + compose_file: &ComposeFile, + alerts: &mut Vec, +) -> Result<()> { + for (name, service) in &compose_file.compose.services { + if let Some(secopts) = &service.security_opt { + let mapping = compose_file + .mappings + .get(format!("services.{}.security_opt", name).as_str()); + + for secopt in secopts { + if secopt.starts_with("no-new-privileges") && secopt.ends_with("false") { + alerts.push(Alert { + id: RuleID::Quibble("SECURITY_OPTS".to_string()), + details: format!( + "Security Opts `no-new-privileges` set to `false` for '{service}'" + ), + severity: Severity::High, + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping.copied(), + }, + }) + } + } + } else { + let mapping = compose_file + .mappings + .get(format!("services.{}", name).as_str()); + + alerts.push(Alert { + id: RuleID::Quibble("SECURITY_OPTS".to_string()), + details: format!("Security Opts `no-new-privileges` not set for '{service}'"), + severity: Severity::High, + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping.copied(), + }, + }) + } + } + Ok(()) +} + +pub fn kernel_parameters( + _config: &Config, + compose_file: &ComposeFile, + alerts: &mut Vec, +) -> Result<()> { + for service in compose_file.compose.services.values() { + if let Some(syscalls) = &service.sysctls { + alerts.push(Alert { + id: RuleID::Quibble("KERNEL_PARAMETERS".to_string()), + details: String::from("Enabling extra syscalls"), + ..Default::default() + }); + + fn syscall_check( + syscall: &String, + compose_file: &ComposeFile, + alerts: &mut Vec, + ) { + if syscall.starts_with("net.ipv4.conf.all") { + alerts.push(Alert { + id: RuleID::Quibble("KERNEL_PARAMETERS".to_string()), + details: format!("IPv4 Kernel Parameters modified: {syscall}"), + severity: Severity::Information, + path: AlertLocation { + path: compose_file.path.clone(), + ..Default::default() + }, + }) + } + } + + match syscalls { + ListOrHashMap::Vec(v) => { + for syscall in v { + match syscall { + StringOrNumber::Str(syscall) => { + syscall_check(syscall, compose_file, alerts); + } + _ => { + debug!("Unsupported syscall type: int / none") + } + } + } + } + ListOrHashMap::Hash(h) => { + for syscall in h.keys() { + syscall_check(syscall, compose_file, alerts); + } + } + } + } + + if let Some(capabilities) = &service.cap_add { + alerts.push(Alert { + id: RuleID::Quibble("KERNEL_PARAMETERS".to_string()), + details: String::from("Using extra Kernel Parameters"), + ..Default::default() + }); + + for cap in capabilities { + // https://man7.org/linux/man-pages/man7/capabilities.7.html + // https://cloud.redhat.com/blog/increasing-security-of-istio-deployments-by-removing-the-need-for-privileged-containers + if cap.contains("NET_ADMIN") { + alerts.push(Alert { + id: RuleID::Quibble("NET_ADMIN".to_string()), + details: String::from("Container with high networking privileages"), + severity: Severity::Medium, + path: AlertLocation { + path: compose_file.path.clone(), + ..Default::default() + }, + }) + } + + if cap.contains("SYS_ADMIN") { + alerts.push(Alert { + id: RuleID::Quibble("SYS_ADMIN".to_string()), + details: String::from("Container with high system privileages"), + severity: Severity::Medium, + path: AlertLocation { + path: compose_file.path.clone(), + ..Default::default() + }, + }) + } + + if cap.contains("ALL") { + alerts.push(Alert { + id: RuleID::Quibble("ALL".to_string()), + details: String::from("All capabilities are enabled"), + severity: Severity::High, + path: AlertLocation { + path: compose_file.path.clone(), + ..Default::default() + }, + ..Default::default() + }) + } + } + } + } + Ok(()) +} diff --git a/src/rules/environment.rs b/src/rules/environment.rs new file mode 100644 index 0000000..ee5c3d3 --- /dev/null +++ b/src/rules/environment.rs @@ -0,0 +1,103 @@ +use anyhow::Result; +use log::debug; + +use crate::{ + compose::{ComposeFile, ListOrHashMap, StringOrNumber}, + config::Config, + security::{Alert, AlertLocation, RuleID, Severity}, +}; + +/// Check environment variables for sensitive information +fn check_environment( + compose_file: &ComposeFile, + alerts: &mut Vec, + service_name: &String, + key: String, + _value: String, +) { + if key.contains("DEBUG") { + let mapping = compose_file + .mappings + .get(format!("services.{}.environment.{}", service_name, key).as_str()); + + alerts.push(Alert { + id: RuleID::Cwe(String::from("1244")), + details: String::from("Debugging enabled in the container"), + severity: Severity::Medium, + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping.copied(), + }, + }) + } + // TODO: better way of detecting this + if key.contains("PASSWORD") || key.contains("KEY") || key.contains("TOKEN") { + let mapping = compose_file + .mappings + .get(format!("services.{}.environment.{}", service_name, key).as_str()); + + alerts.push(Alert { + id: RuleID::Cwe(String::from("215")), + details: String::from("Possible Hardcoded password"), + severity: Severity::Low, + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping.copied(), + }, + }) + } +} + +/// Environment Variable rules +pub fn environment_variables( + _config: &Config, + compose_file: &ComposeFile, + alerts: &mut Vec, +) -> Result<()> { + for (name, service) in &compose_file.compose.services { + if let Some(envvars) = &service.environment { + match envvars { + ListOrHashMap::Vec(envvec) => { + for envvar in envvec { + match envvar { + StringOrNumber::Str(str) => { + if let Some((key, value)) = str.split_once('=') { + check_environment( + compose_file, + alerts, + name, + key.to_string(), + value.to_string(), + ) + } + } + _ => { + debug!("Unsupported type check int / none: {}", compose_file) + } + } + } + } + ListOrHashMap::Hash(envhash) => { + // warn!("Unsupported feature: envvars HashMap for {}", compose_file); + for (key, value) in envhash { + match value { + StringOrNumber::Str(value) => { + check_environment( + compose_file, + alerts, + name, + key.to_string(), + value.to_string(), + ); + } + _ => { + debug!("Unsupported type check int / none: {}", compose_file) + } + } + } + } + } + } + } + Ok(()) +} diff --git a/src/rules/images.rs b/src/rules/images.rs new file mode 100644 index 0000000..81de490 --- /dev/null +++ b/src/rules/images.rs @@ -0,0 +1,99 @@ +use anyhow::Result; + +use crate::{ + compose::{ComposeFile, StringOrBuild}, + config::Config, + security::{Alert, AlertLocation, RuleID, Severity}, +}; + +/// Container Images +pub fn container_images( + _config: &Config, + compose_file: &ComposeFile, + alerts: &mut Vec, +) -> Result<()> { + for (name, service) in &compose_file.compose.services { + // Manually building project + if let Some(build_enum) = &service.build { + let mapping_line = compose_file + .mappings + .get(format!("services.{}.build", name).as_str()); + + match build_enum { + StringOrBuild::Str(context) => alerts.push(Alert { + id: RuleID::Quibble("BUILD_CONTEXT".to_string()), + details: format!("Build context path: {context}"), + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping_line.copied(), + }, + ..Default::default() + }), + StringOrBuild::Build(build) => { + if let Some(context) = &build.context { + alerts.push(Alert { + id: RuleID::Quibble("BUILD_CONTEXT".to_string()), + details: format!("Build context path: {context}"), + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping_line.copied(), + }, + ..Default::default() + }) + } + } + } + } + + // Pulling remote image + if let Some(image) = &service.image { + let mapping_line = compose_file + .mappings + .get(format!("services.{}.image", name).as_str()); + + // Format strings + if image.contains("${") { + alerts.push(Alert { + id: RuleID::Quibble("IMAGE_ENV_VAR".to_string()), + details: format!("Container Image using Environment Variable: {image}"), + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping_line.copied(), + }, + ..Default::default() + }) + } else if let Ok(container) = service.parse_image() { + alerts.push(Alert { + id: RuleID::Quibble("IMAGE_TAG".to_string()), + details: format!("Container Image: {container}"), + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping_line.copied(), + }, + ..Default::default() + }); + + // Rule: Pinned to latest rolling container image + // - The main reason behind this is if you are using watchtower or other + // service to update containers it might cause issues + let latest = vec!["latest", "main", "master"]; + if latest.contains(&container.tag.as_str()) { + alerts.push(Alert { + id: RuleID::Quibble("IMAGE_TAG_LATEST".to_string()), + details: format!( + "Container using rolling release tag: `{}`", + container.tag + ), + severity: Severity::Medium, + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping_line.copied(), + }, + ..Default::default() + }); + } + } + } + } + Ok(()) +} diff --git a/src/rules/registry.rs b/src/rules/registry.rs new file mode 100644 index 0000000..19d04b3 --- /dev/null +++ b/src/rules/registry.rs @@ -0,0 +1,33 @@ +use anyhow::Result; + +use crate::{ + compose::ComposeFile, + config::Config, + security::{Alert, AlertLocation, RuleID, Severity}, +}; + +/// Docker registry Rule +/// +/// Make sure that the container is being pulled from a trusted source +pub fn docker_registry( + config: &Config, + compose_file: &ComposeFile, + alerts: &mut Vec, +) -> Result<()> { + for service in compose_file.compose.services.values() { + if let Ok(container) = service.parse_image() { + if !config.registries.contains(&container.instance) { + alerts.push(Alert { + id: RuleID::Quibble("DOCKER_REGISTRY".to_string()), + details: format!("Container from unknown registry: {}", &container.instance), + severity: Severity::High, + path: AlertLocation { + path: compose_file.path.clone(), + ..Default::default() + }, + }); + } + } + } + Ok(()) +} diff --git a/src/rules/socket.rs b/src/rules/socket.rs new file mode 100644 index 0000000..62bac38 --- /dev/null +++ b/src/rules/socket.rs @@ -0,0 +1,44 @@ +// her + +use anyhow::Result; +use log::debug; + +use crate::{ + compose::ComposeFile, + config::Config, + security::{Alert, AlertLocation, RuleID, Severity}, +}; + +/// Docker Socket Rule +pub fn docker_socket( + _config: &Config, + compose_file: &ComposeFile, + alerts: &mut Vec, +) -> Result<()> { + debug!("Docker Socker Rule enabled..."); + + for (name, service) in &compose_file.compose.services { + if let Some(volumes) = &service.volumes { + let result = volumes + .iter() + .find(|&s| s.starts_with("/var/run/docker.sock")); + + if result.is_some() { + let mapping_line = compose_file + .mappings + .get(format!("services.{}.volumes", name).as_str()); + + alerts.push(Alert { + id: RuleID::Quibble("DOCKER_SOCKET".to_string()), + details: String::from("Docker Socket being passed into container"), + severity: Severity::High, + path: AlertLocation { + path: compose_file.path.clone(), + line: mapping_line.copied(), + }, + }) + } + } + } + Ok(()) +} diff --git a/src/rules/version.rs b/src/rules/version.rs new file mode 100644 index 0000000..e0c67fe --- /dev/null +++ b/src/rules/version.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use log::debug; + +use crate::{ + compose::ComposeFile, + config::Config, + security::{Alert, AlertLocation, RuleID, Severity}, +}; + +/// Check which compose spec version is being used +pub fn docker_version( + _config: &Config, + compose_file: &ComposeFile, + alerts: &mut Vec, +) -> Result<()> { + debug!("Compose Version rule enabled..."); + // https://docs.docker.com/compose/compose-file/compose-versioning/ + if let Some(version) = &compose_file.compose.version { + match version.as_str() { + "1" => alerts.push(Alert { + id: RuleID::Quibble("COMPOSE_V1".to_string()), + details: String::from("Compose v1"), + severity: Severity::Medium, + path: AlertLocation { + path: compose_file.path.clone(), + line: compose_file.mappings.get("version").copied(), + }, + }), + "2" | "2.0" | "2.1" | "2.2" | "2.3" | "2.4" => alerts.push(Alert { + id: RuleID::Quibble("COMPOSE_V2".to_string()), + details: String::from("Compose v2 used"), + severity: Severity::Low, + path: AlertLocation { + path: compose_file.path.clone(), + line: compose_file.mappings.get("version").copied(), + }, + }), + "3" | "3.0" | "3.1" | "3.2" | "3.3" | "3.4" | "3.5" => alerts.push(Alert { + id: RuleID::Quibble("COMPOSE_V3".to_string()), + details: String::from("Using old Compose v3 spec, consider upgrading"), + severity: Severity::Low, + path: AlertLocation { + path: compose_file.path.clone(), + line: compose_file.mappings.get("version").copied(), + }, + }), + _ => { + debug!("Unknown or secure version of Docker Compose") + } + } + } + Ok(()) +} diff --git a/src/security.rs b/src/security.rs index 2dcd336..edf0d96 100644 --- a/src/security.rs +++ b/src/security.rs @@ -4,11 +4,7 @@ use std::{cell::RefCell, fmt::Display, ops::Index, path::PathBuf, rc::Rc}; use anyhow::Result; use log::{error, warn}; -use crate::{ - compose::{rules, ComposeFile}, - config::Config, - security, -}; +use crate::{compose::ComposeFile, config::Config, rules::*, security}; const SEVERITIES: &[&str; 10] = &[ "critical", @@ -183,59 +179,6 @@ impl Display for AlertLocation { } } -pub type Rule = dyn Fn(&Config, &ComposeFile, &mut Vec) -> Result<()>; - -pub struct Rules<'rules> { - config: &'rules Config, - rules: Vec>>, -} - -impl<'rules> Rules<'rules> { - pub fn new(config: &'rules Config) -> Self { - let mut rules = Rules { - config, - rules: Vec::new(), - }; - - if !config.disable_rules { - rules - .register(&rules::docker_version) - .register(&rules::docker_socket) - .register(&rules::docker_registry) - .register(&rules::container_images) - .register(&rules::kernel_parameters) - .register(&rules::security_opts) - .register(&rules::privileged) - .register(&rules::environment_variables); - } - - rules - } - - pub fn run(&mut self, compose_file: &ComposeFile) -> Vec { - let mut alerts: Vec = Vec::new(); - for rule in self.rules.iter() { - let mut closure = rule.borrow_mut(); - if let Err(err) = (*closure)(self.config, compose_file, &mut alerts) { - error!("Error during rule execution: {err:?}"); - } - } - // Sort by severity - alerts.sort_by(|a, b| a.severity.cmp(&b.severity)); - alerts - } - - pub fn register(&mut self, rule: &'rules Rule) -> &mut Self { - let cell = Rc::new(RefCell::new(rule)); - self.rules.push(cell); - self - } - - pub fn len(&self) -> usize { - self.rules.len() - } -} - #[cfg(test)] mod tests { use crate::{ From 5ce5162b23c6196f877af032e7729f0b2187dee9 Mon Sep 17 00:00:00 2001 From: GeekMasher Date: Thu, 3 Aug 2023 18:25:34 +0100 Subject: [PATCH 2/2] feat: v0.3.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8bd41df..c16710e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,7 +346,7 @@ dependencies = [ [[package]] name = "quibble" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 3f2d512..2c06165 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quibble" -version = "0.3.1" +version = "0.3.2" authors = ['GeekMasher'] description = "A container security tool written in Rust focusing on compose based configuration as code"