use crate::core::{Finding, Severity};
use crate::plugins::traits::{PluginError, PluginReport, ScanContext, ScanPhase, SecurityPlugin};
use async_trait::async_trait;
use lazy_static::lazy_static;
use regex::Regex;
use std::path::Path;
use std::time::Instant;
lazy_static! {
static ref DOCKERFILE_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)^FROM\s+[^\s]+\s+AS\s+\w+|^FROM\s+[^\s]+$").unwrap(),
"Dockerfile FROM without digest pinning",
Severity::Low,
"Use FROM image@sha256:... to pin images and prevent supply chain attacks.",
),
(
Regex::new(r"(?i)^USER\s+root\s*$").unwrap(),
"Container running as root",
Severity::High,
"Running as root inside a container increases blast radius. Use a non-root user. CWE-250.",
),
(
Regex::new(r#"(?i)--security-opt\s*=?\s*['"]?no-new-privileges\s*[:=]\s*false"#).unwrap(),
"no-new-privileges disabled",
Severity::High,
"Disabling no-new-privileges allows privilege escalation within the container.",
),
(
Regex::new(r"(?i)COPY\s+.*\.(pem|key|p12|pfx|jks)\b").unwrap(),
"Private key/certificate copied into Docker image",
Severity::Critical,
"Secrets baked into images persist in layer history. Use runtime secrets instead.",
),
(
Regex::new(r"(?i)(ENV|ARG)\s+\w*(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL)\w*\s*=").unwrap(),
"Secret in Dockerfile ENV/ARG",
Severity::Critical,
"Secrets in ENV/ARG are visible in image history. Use runtime mounts or secrets.",
),
(
Regex::new(r"(?i)curl\s+.*\|\s*(ba)?sh").unwrap(),
"Pipe curl to shell in Dockerfile",
Severity::High,
"Downloading and executing scripts in Dockerfile is a supply chain risk. Download, verify, then execute.",
),
(
Regex::new(r"(?i)--privileged").unwrap(),
"Privileged flag in Docker command",
Severity::Critical,
"The --privileged flag grants the container full host access. CWE-250.",
),
(
Regex::new(r"(?i)ADD\s+https?://").unwrap(),
"Dockerfile ADD from URL",
Severity::Medium,
"ADD from URL downloads unverified content. Use COPY with prior verification instead.",
),
];
static ref COMPOSE_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)privileged:\s*(true|yes)").unwrap(),
"Docker Compose privileged container",
Severity::Critical,
"Privileged mode grants the container full host kernel access. CWE-250.",
),
(
Regex::new(r"(?i)/var/run/docker\.sock").unwrap(),
"Docker socket mounted into container",
Severity::Critical,
"Mounting the Docker socket grants full control over the Docker daemon — equivalent to root on host. CWE-269.",
),
(
Regex::new(r#"(?i)network_mode:\s*['"]?host"#).unwrap(),
"Container using host network",
Severity::High,
"Host network mode exposes all host ports and bypasses network isolation.",
),
(
Regex::new(r#"(?i)pid:\s*['"]?host"#).unwrap(),
"Container using host PID namespace",
Severity::High,
"Host PID namespace allows the container to see and signal host processes.",
),
(
Regex::new(r#"(?i)ipc:\s*['"]?host"#).unwrap(),
"Container using host IPC namespace",
Severity::High,
"Host IPC namespace allows shared memory access with host processes.",
),
(
Regex::new(r"(?i)cap_add:\s*\n\s*-\s*(ALL|SYS_ADMIN|SYS_PTRACE|NET_ADMIN|DAC_READ_SEARCH)").unwrap(),
"Dangerous Linux capability added to container",
Severity::Critical,
"Adding powerful capabilities like SYS_ADMIN or ALL enables container escape. CWE-250.",
),
(
Regex::new(r"(?i)- SYS_ADMIN").unwrap(),
"SYS_ADMIN capability added",
Severity::Critical,
"SYS_ADMIN enables mount namespace manipulation and container escape.",
),
(
Regex::new(r"(?i)- ALL\b").unwrap(),
"ALL capabilities added",
Severity::Critical,
"cap_add: ALL grants every Linux capability to the container.",
),
(
Regex::new(r"(?i)nsenter\s+.*-t\s*(1|\$\(|\`)").unwrap(),
"nsenter container escape",
Severity::Critical,
"nsenter with PID 1 is a container escape technique that enters the host namespaces.",
),
];
static ref K8S_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)privileged:\s*(true|yes)").unwrap(),
"Kubernetes privileged pod",
Severity::Critical,
"Privileged pods have full access to the host kernel. CWE-250.",
),
(
Regex::new(r"(?i)hostNetwork:\s*(true|yes)").unwrap(),
"Kubernetes hostNetwork enabled",
Severity::High,
"hostNetwork exposes the pod to the host network stack.",
),
(
Regex::new(r"(?i)hostPID:\s*(true|yes)").unwrap(),
"Kubernetes hostPID enabled",
Severity::High,
"hostPID allows the pod to see host processes.",
),
(
Regex::new(r"(?i)hostIPC:\s*(true|yes)").unwrap(),
"Kubernetes hostIPC enabled",
Severity::High,
"hostIPC allows shared memory access with host.",
),
(
Regex::new(r"(?i)hostPath:").unwrap(),
"Kubernetes hostPath volume mount",
Severity::High,
"hostPath mounts can expose sensitive host directories to the pod.",
),
(
Regex::new(r"(?i)allowPrivilegeEscalation:\s*(true|yes)").unwrap(),
"Kubernetes allowPrivilegeEscalation enabled",
Severity::High,
"Allows processes to gain more privileges than their parent.",
),
(
Regex::new(r"(?i)readOnlyRootFilesystem:\s*(false|no)").unwrap(),
"Kubernetes writable root filesystem",
Severity::Medium,
"A writable root filesystem increases the attack surface.",
),
(
Regex::new(r"(?i)runAsUser:\s*0\b").unwrap(),
"Kubernetes pod running as root (UID 0)",
Severity::High,
"Running as root inside a pod increases blast radius.",
),
(
Regex::new(r"(?i)automountServiceAccountToken:\s*(true|yes)").unwrap(),
"Kubernetes service account token auto-mounted",
Severity::Medium,
"Auto-mounted service account tokens can be used for lateral movement.",
),
(
Regex::new(r#"(?i)verbs:\s*\[?"?\*"?\]?"#).unwrap(),
"Kubernetes RBAC wildcard verb",
Severity::Critical,
"Wildcard verbs grant all actions on the specified resources. CWE-269.",
),
(
Regex::new(r#"(?i)resources:\s*\[?"?\*"?\]?"#).unwrap(),
"Kubernetes RBAC wildcard resource",
Severity::Critical,
"Wildcard resources with broad verbs grants cluster-admin-equivalent access.",
),
(
Regex::new(r"(?i)verbs:.*\b(escalate|bind|impersonate)\b").unwrap(),
"Kubernetes RBAC privilege escalation verb",
Severity::Critical,
"escalate/bind/impersonate verbs enable RBAC privilege escalation. CWE-269.",
),
(
Regex::new(r"(?i)/var/run/secrets/kubernetes\.io").unwrap(),
"Kubernetes service account token path reference",
Severity::Medium,
"Direct reference to service account token path — verify this isn't being exfiltrated.",
),
(
Regex::new(r"(?i)nsenter\s+.*-t\s*(1|\$)").unwrap(),
"nsenter container escape in K8s",
Severity::Critical,
"nsenter targeting PID 1 is a container escape into the host.",
),
];
}
pub struct ContainerScanner;
impl Default for ContainerScanner {
fn default() -> Self {
Self::new()
}
}
impl ContainerScanner {
pub fn new() -> Self {
Self
}
fn is_container_file(path: &Path) -> ContainerFileType {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let path_str = path.to_string_lossy().to_lowercase();
if filename == "Dockerfile"
|| filename.starts_with("Dockerfile.")
|| filename.ends_with(".dockerfile")
{
return ContainerFileType::Dockerfile;
}
if filename == "docker-compose.yml"
|| filename == "docker-compose.yaml"
|| filename.starts_with("docker-compose.")
|| filename == "compose.yml"
|| filename == "compose.yaml"
{
return ContainerFileType::DockerCompose;
}
if (path_str.ends_with(".yml") || path_str.ends_with(".yaml"))
&& (path_str.contains("k8s")
|| path_str.contains("kubernetes")
|| path_str.contains("deploy")
|| path_str.contains("manifest")
|| path_str.contains("helm")
|| path_str.contains("container"))
{
return ContainerFileType::Kubernetes;
}
ContainerFileType::NotContainer
}
fn is_kubernetes_content(content: &str) -> bool {
content.contains("apiVersion:") && content.contains("kind:")
}
fn apply_patterns(
path: &Path,
content: &str,
patterns: &[(Regex, &'static str, Severity, &'static str)],
findings: &mut Vec<Finding>,
) {
for (line_num, line) in content.lines().enumerate() {
for (pattern, title, severity, description) in patterns.iter() {
if pattern.is_match(line) {
findings.push(
Finding::new(
format!("CTR-{:03}", findings.len() + 1),
title.to_string(),
*severity,
)
.with_file(path.to_path_buf())
.with_line((line_num + 1) as u32)
.with_evidence(line.trim().to_string())
.with_description(description.to_string()),
);
}
}
}
}
}
#[derive(Debug, PartialEq)]
enum ContainerFileType {
Dockerfile,
DockerCompose,
Kubernetes,
NotContainer,
}
#[async_trait]
impl SecurityPlugin for ContainerScanner {
fn name(&self) -> &str {
"container"
}
fn version(&self) -> &str {
"0.1.0"
}
fn description(&self) -> &str {
"Detect container and Kubernetes security misconfigurations"
}
fn scan_phase(&self) -> ScanPhase {
ScanPhase::All
}
async fn initialize(&mut self) -> Result<(), PluginError> {
Ok(())
}
async fn scan(&self, context: &ScanContext<'_>) -> Result<PluginReport, PluginError> {
let start = Instant::now();
let mut report = PluginReport::new(self.name().to_string());
let file_type = Self::is_container_file(context.path);
if let Some(content) = context.file_content {
let content_str = String::from_utf8_lossy(content);
match file_type {
ContainerFileType::Dockerfile => {
Self::apply_patterns(
context.path,
&content_str,
&DOCKERFILE_PATTERNS,
&mut report.findings,
);
report.scanned_files = 1;
}
ContainerFileType::DockerCompose => {
Self::apply_patterns(
context.path,
&content_str,
&COMPOSE_PATTERNS,
&mut report.findings,
);
report.scanned_files = 1;
}
ContainerFileType::Kubernetes => {
Self::apply_patterns(
context.path,
&content_str,
&K8S_PATTERNS,
&mut report.findings,
);
report.scanned_files = 1;
}
ContainerFileType::NotContainer => {
let path_str = context.path.to_string_lossy();
if (path_str.ends_with(".yml") || path_str.ends_with(".yaml"))
&& Self::is_kubernetes_content(&content_str)
{
Self::apply_patterns(
context.path,
&content_str,
&K8S_PATTERNS,
&mut report.findings,
);
report.scanned_files = 1;
}
}
}
}
report.duration_ms = start.elapsed().as_millis() as u64;
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::traits::ScanContext;
use std::collections::HashMap;
#[tokio::test]
async fn test_privileged_pod() {
let scanner = ContainerScanner::new();
let content = b"apiVersion: v1\nkind: Pod\nspec:\n containers:\n - securityContext:\n privileged: true";
let context = ScanContext {
path: Path::new("k8s-pod.yaml"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.title.contains("privileged")));
}
#[tokio::test]
async fn test_docker_socket_mount() {
let scanner = ContainerScanner::new();
let content = b"volumes:\n - /var/run/docker.sock:/var/run/docker.sock";
let context = ScanContext {
path: Path::new("docker-compose.yml"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.title.contains("Docker socket")));
}
#[tokio::test]
async fn test_rbac_wildcard() {
let scanner = ContainerScanner::new();
let content = b"apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nrules:\n- verbs: [\"*\"]\n resources: [\"*\"]";
let context = ScanContext {
path: Path::new("k8s-rbac.yaml"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.severity == Severity::Critical));
}
#[tokio::test]
async fn test_dockerfile_secret_env() {
let scanner = ContainerScanner::new();
let content = b"FROM ubuntu:20.04\nENV DB_PASSWORD=supersecret\nRUN apt-get update";
let context = ScanContext {
path: Path::new("Dockerfile"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.title.contains("Secret in Dockerfile")));
}
}