mod detectors;
use std::path::Path;
use crate::artifact_graph::{ArtifactCapability, ArtifactCapabilityFact, ArtifactRelation};
use crate::findings::Finding;
use crate::services::artifact_orchestration::{ArtifactLink, ArtifactOrchestratorService};
use super::volumes::{env_file_has_real_paths, is_sensitive_host_volume, volume_entry_string};
use detectors::{
detect_dangerous_cap_add, detect_env_file, detect_host_network, detect_host_volumes,
detect_latest_image_tag, detect_privileged, mapping_declares_dangerous_cap, parse_compose_yaml,
parse_failure_finding,
};
pub(crate) fn analyze_docker_compose(path: &Path, content: &str) -> Vec<Finding> {
let artifact_path = path.display().to_string();
let yaml = match parse_compose_yaml(content) {
Ok(value) => value,
Err(err) => return vec![parse_failure_finding(&artifact_path, &err)],
};
let Some(services) = yaml.get("services").and_then(serde_yaml::Value::as_mapping) else {
return Vec::new();
};
let mut findings = Vec::new();
for_each_service(services, |service_name, mapping| {
findings.extend(detect_latest_image_tag(
service_name,
mapping,
&artifact_path,
));
findings.extend(detect_privileged(service_name, mapping, &artifact_path));
findings.extend(detect_dangerous_cap_add(
service_name,
mapping,
&artifact_path,
));
findings.extend(detect_host_volumes(service_name, mapping, &artifact_path));
findings.extend(detect_host_network(service_name, mapping, &artifact_path));
findings.extend(detect_env_file(service_name, mapping, &artifact_path));
});
findings
}
fn with_compose_services<F: FnMut(&str, &serde_yaml::Mapping)>(content: &str, mut visit: F) {
let Ok(yaml) = parse_compose_yaml(content) else {
return;
};
let Some(services) = yaml.get("services").and_then(serde_yaml::Value::as_mapping) else {
return;
};
for_each_service(services, |service_name, mapping| {
visit(service_name, mapping);
});
}
fn for_each_service<F: FnMut(&str, &serde_yaml::Mapping)>(
services: &serde_yaml::Mapping,
mut visit: F,
) {
for (raw_name, service) in services {
let service_name = raw_name.as_str().unwrap_or("unknown");
let Some(mapping) = service.as_mapping() else {
continue;
};
visit(service_name, mapping);
}
}
pub(crate) fn docker_compose_capabilities(content: &str) -> Vec<ArtifactCapabilityFact> {
let mut capabilities: Vec<ArtifactCapabilityFact> = Vec::new();
with_compose_services(content, |_, mapping| {
let privileged = mapping
.get(serde_yaml::Value::String("privileged".to_string()))
.and_then(serde_yaml::Value::as_bool)
.unwrap_or(false);
if (privileged || mapping_declares_dangerous_cap(mapping))
&& !capabilities.iter().any(|fact| {
fact.capability == ArtifactCapability::PrivilegedRuntime
&& fact.source == crate::artifact_graph::ArtifactCapabilitySource::Declared
})
{
capabilities.push(ArtifactOrchestratorService::declared_capability(
ArtifactCapability::PrivilegedRuntime,
));
}
if let Some(volumes) = mapping
.get(serde_yaml::Value::String("volumes".to_string()))
.and_then(serde_yaml::Value::as_sequence)
{
if volumes.iter().any(|volume| {
volume_entry_string(volume).is_some_and(|v| is_sensitive_host_volume(&v))
}) && !capabilities.iter().any(|fact| {
fact.capability == ArtifactCapability::HostFilesystemAccess
&& fact.source == crate::artifact_graph::ArtifactCapabilitySource::Declared
}) {
capabilities.push(ArtifactOrchestratorService::declared_capability(
ArtifactCapability::HostFilesystemAccess,
));
capabilities.push(ArtifactOrchestratorService::declared_capability(
ArtifactCapability::FilesystemWrite,
));
}
}
if mapping.contains_key(serde_yaml::Value::String("ports".to_string()))
&& !capabilities.iter().any(|fact| {
fact.capability == ArtifactCapability::NetworkAccess
&& fact.source == crate::artifact_graph::ArtifactCapabilitySource::Declared
})
{
capabilities.push(ArtifactOrchestratorService::declared_capability(
ArtifactCapability::NetworkAccess,
));
}
if mapping
.get(serde_yaml::Value::String("env_file".to_string()))
.is_some_and(env_file_has_real_paths)
&& !capabilities.iter().any(|fact| {
fact.capability == ArtifactCapability::SecretAccess
&& fact.source == crate::artifact_graph::ArtifactCapabilitySource::Declared
})
{
capabilities.push(ArtifactOrchestratorService::declared_capability(
ArtifactCapability::SecretAccess,
));
}
if (mapping.contains_key(serde_yaml::Value::String("command".to_string()))
|| mapping.contains_key(serde_yaml::Value::String("entrypoint".to_string())))
&& !capabilities.iter().any(|fact| {
fact.capability == ArtifactCapability::ProcessExecution
&& fact.source == crate::artifact_graph::ArtifactCapabilitySource::Declared
})
{
capabilities.push(ArtifactOrchestratorService::declared_capability(
ArtifactCapability::ProcessExecution,
));
}
});
capabilities
}
pub(crate) fn docker_compose_relations(content: &str) -> Vec<ArtifactLink> {
let mut links: Vec<ArtifactLink> = Vec::new();
with_compose_services(content, |_, mapping| {
if let Some(image) = mapping
.get(serde_yaml::Value::String("image".to_string()))
.and_then(serde_yaml::Value::as_str)
{
links.push(ArtifactLink {
target: image.to_string(),
relation: ArtifactRelation::Loads,
});
}
if mapping.contains_key(serde_yaml::Value::String("ports".to_string())) {
links.push(ArtifactLink {
target: "network".to_string(),
relation: ArtifactRelation::ConnectsTo,
});
}
if mapping.contains_key(serde_yaml::Value::String("volumes".to_string())) {
links.push(ArtifactLink {
target: "host-filesystem".to_string(),
relation: ArtifactRelation::Mounts,
});
}
if mapping.contains_key(serde_yaml::Value::String("env_file".to_string())) {
links.push(ArtifactLink {
target: ".env".to_string(),
relation: ArtifactRelation::AccessesSecrets,
});
}
if mapping.contains_key(serde_yaml::Value::String("command".to_string()))
|| mapping.contains_key(serde_yaml::Value::String("entrypoint".to_string()))
{
links.push(ArtifactLink {
target: "process".to_string(),
relation: ArtifactRelation::Executes,
});
}
});
links
}
#[cfg(test)]
mod tests {
use super::*;
fn capability_present(caps: &[ArtifactCapabilityFact], target: ArtifactCapability) -> bool {
caps.iter().any(|fact| fact.capability == target)
}
fn finding_present(findings: &[Finding], rule_id: &str) -> bool {
findings.iter().any(|finding| finding.rule_id == rule_id)
}
fn match_value_for(findings: &[Finding], rule_id: &str) -> Option<String> {
findings
.iter()
.find(|finding| finding.rule_id == rule_id)
.map(|finding| finding.match_value.clone())
}
#[test]
fn docker_compose_host_filesystem_capability_skips_relative_project_volume() {
let yaml = "services:\n app:\n image: nginx\n volumes:\n - \"./data:/data\"\n";
let caps = docker_compose_capabilities(yaml);
assert!(!capability_present(
&caps,
ArtifactCapability::HostFilesystemAccess
));
assert!(!capability_present(
&caps,
ArtifactCapability::FilesystemWrite
));
}
#[test]
fn docker_compose_host_filesystem_capability_fires_for_docker_socket() {
let yaml = "services:\n app:\n image: nginx\n volumes:\n - \"/var/run/docker.sock:/var/run/docker.sock\"\n";
let caps = docker_compose_capabilities(yaml);
assert!(capability_present(
&caps,
ArtifactCapability::HostFilesystemAccess
));
assert!(capability_present(
&caps,
ArtifactCapability::FilesystemWrite
));
}
#[test]
fn docker_compose_host_filesystem_capability_fires_for_etc_mount() {
let yaml =
"services:\n app:\n image: nginx\n volumes:\n - \"/etc/passwd:/host-etc/passwd\"\n";
let caps = docker_compose_capabilities(yaml);
assert!(capability_present(
&caps,
ArtifactCapability::HostFilesystemAccess
));
}
#[test]
fn docker_compose_host_mount_finding_skips_relative_project_volume() {
let yaml = "services:\n app:\n image: nginx\n volumes:\n - \"./data:/data\"\n";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
assert!(!finding_present(
&findings,
"MANIFEST_DOCKER_COMPOSE_HOST_MOUNT"
));
}
#[test]
fn docker_compose_env_file_finding_skips_null_value() {
let yaml = "services:\n app:\n image: nginx\n env_file: null\n";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
let caps = docker_compose_capabilities(yaml);
assert!(!finding_present(
&findings,
"MANIFEST_DOCKER_COMPOSE_ENV_FILE"
));
assert!(!capability_present(&caps, ArtifactCapability::SecretAccess));
}
#[test]
fn docker_compose_env_file_finding_skips_empty_sequence() {
let yaml = "services:\n app:\n image: nginx\n env_file: []\n";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
let caps = docker_compose_capabilities(yaml);
assert!(!finding_present(
&findings,
"MANIFEST_DOCKER_COMPOSE_ENV_FILE"
));
assert!(!capability_present(&caps, ArtifactCapability::SecretAccess));
}
#[test]
fn docker_compose_env_file_finding_uses_clean_match_value_for_string() {
let yaml = "services:\n app:\n image: nginx\n env_file: .env\n";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
let value = match_value_for(&findings, "MANIFEST_DOCKER_COMPOSE_ENV_FILE")
.expect("env_file finding should fire for non-empty string");
assert_eq!(value, "app: .env");
assert!(!value.contains("String("));
}
#[test]
fn docker_compose_env_file_finding_uses_clean_match_value_for_sequence() {
let yaml =
"services:\n app:\n image: nginx\n env_file:\n - .env\n - .env.prod\n";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
let value = match_value_for(&findings, "MANIFEST_DOCKER_COMPOSE_ENV_FILE")
.expect("env_file finding should fire for non-empty sequence");
assert_eq!(value, "app: .env, .env.prod");
assert!(!value.contains("String("));
}
#[test]
fn analyze_docker_compose_emits_parse_failure_finding_for_invalid_yaml() {
let bad = "services: [unterminated\n app:\n image: nginx\n";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, bad);
assert!(
finding_present(&findings, "MANIFEST_DOCKER_COMPOSE_PARSE_FAILURE"),
"invalid YAML must produce a parse-failure finding; got {findings:?}",
);
let only_parse_failure = findings
.iter()
.all(|f| f.rule_id == "MANIFEST_DOCKER_COMPOSE_PARSE_FAILURE");
assert!(
only_parse_failure,
"no other detector should fire on invalid YAML; got {findings:?}",
);
}
#[test]
fn analyze_docker_compose_does_not_emit_parse_failure_for_valid_yaml() {
let good = "services:\n app:\n image: nginx:1.25\n";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, good);
assert!(
!finding_present(&findings, "MANIFEST_DOCKER_COMPOSE_PARSE_FAILURE"),
"valid YAML must not produce a parse-failure finding; got {findings:?}",
);
}
#[test]
fn analyze_docker_compose_detects_long_syntax_docker_socket_mount() {
let yaml = "\
services:
app:
image: nginx
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
assert!(
finding_present(&findings, "MANIFEST_DOCKER_COMPOSE_HOST_MOUNT"),
"long-syntax docker-socket bind mount must fire HOST_MOUNT; got {findings:?}",
);
}
#[test]
fn docker_compose_capabilities_detect_long_syntax_etc_mount() {
let yaml = "\
services:
app:
image: nginx
volumes:
- type: bind
source: /etc
target: /host-etc
";
let caps = docker_compose_capabilities(yaml);
assert!(
capability_present(&caps, ArtifactCapability::HostFilesystemAccess),
"long-syntax /etc bind mount must escalate HostFilesystemAccess; got {caps:?}",
);
assert!(
capability_present(&caps, ArtifactCapability::FilesystemWrite),
"long-syntax /etc bind mount must escalate FilesystemWrite; got {caps:?}",
);
}
#[test]
fn docker_compose_capabilities_skip_long_syntax_named_volume() {
let yaml = "\
services:
app:
image: nginx
volumes:
- type: volume
source: db-data
target: /var/lib/db
";
let caps = docker_compose_capabilities(yaml);
assert!(
!capability_present(&caps, ArtifactCapability::HostFilesystemAccess),
"named volume must not escalate HostFilesystemAccess; got {caps:?}",
);
}
#[test]
fn analyze_docker_compose_skips_host_alias_lookalike_destinations() {
let yaml = "\
services:
app:
image: nginx
volumes:
- \"./data:/hostname\"
- \"./data:/host-backup\"
- \"./data:/hostpath\"
";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
assert!(
!finding_present(&findings, "MANIFEST_DOCKER_COMPOSE_HOST_MOUNT"),
"`/host*` look-alike destinations on relative sources must not fire HOST_MOUNT; got {findings:?}",
);
let caps = docker_compose_capabilities(yaml);
assert!(
!capability_present(&caps, ArtifactCapability::HostFilesystemAccess),
"`/host*` look-alike destinations must not escalate HostFilesystemAccess; got {caps:?}",
);
}
#[test]
fn analyze_docker_compose_detects_exact_host_alias_destination() {
let yaml = "\
services:
app:
image: nginx
volumes:
- \"./data:/host:ro\"
";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
assert!(
finding_present(&findings, "MANIFEST_DOCKER_COMPOSE_HOST_MOUNT"),
"exact `:/host` alias must still fire HOST_MOUNT; got {findings:?}",
);
}
#[test]
fn docker_compose_cap_add_sys_admin_fires_finding_and_capability() {
let yaml = "\
services:
app:
image: ubuntu:22.04
cap_add:
- SYS_ADMIN
- NET_ADMIN
";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
let caps = docker_compose_capabilities(yaml);
assert!(
finding_present(&findings, "MANIFEST_DOCKER_COMPOSE_DANGEROUS_CAP_ADD"),
"expected DANGEROUS_CAP_ADD finding; got {findings:?}"
);
assert!(
capability_present(&caps, ArtifactCapability::PrivilegedRuntime),
"cap_add SYS_ADMIN must escalate PrivilegedRuntime; got {caps:?}"
);
}
#[test]
fn docker_compose_cap_add_accepts_cap_prefixed_form() {
let yaml = "\
services:
app:
image: ubuntu:22.04
cap_add:
- CAP_SYS_ADMIN
";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
let caps = docker_compose_capabilities(yaml);
assert!(finding_present(
&findings,
"MANIFEST_DOCKER_COMPOSE_DANGEROUS_CAP_ADD"
));
assert!(capability_present(
&caps,
ArtifactCapability::PrivilegedRuntime
));
}
#[test]
fn docker_compose_cap_add_does_not_fire_on_benign_capabilities() {
let yaml = "\
services:
app:
image: ubuntu:22.04
cap_add:
- NET_BIND_SERVICE
- CHOWN
- KILL
";
let path = std::path::Path::new("/pkg/docker-compose.yml");
let findings = analyze_docker_compose(path, yaml);
let caps = docker_compose_capabilities(yaml);
assert!(!finding_present(
&findings,
"MANIFEST_DOCKER_COMPOSE_DANGEROUS_CAP_ADD"
));
assert!(!capability_present(
&caps,
ArtifactCapability::PrivilegedRuntime
));
}
}