pub(super) fn matches_root_path(volume: &str, root: &str) -> bool {
if volume == root {
return true;
}
if let Some(rest) = volume.strip_prefix(root) {
return rest.starts_with('/') || rest.starts_with(':');
}
false
}
pub(super) fn is_sensitive_host_volume(volume: &str) -> bool {
let normalised = normalise_volume_for_classification(volume);
let v = normalised.as_str();
v.starts_with("/:")
|| destination_is_host_alias(v)
|| matches_root_path(v, "/var/run/docker.sock")
|| v.starts_with("/etc/")
|| matches_root_path(v, "/root")
|| matches_root_path(v, "/proc")
|| matches_root_path(v, "/sys")
|| (v.starts_with('/') && v.contains(":/"))
}
fn normalise_volume_for_classification(volume: &str) -> String {
let (source, rest) = match volume.split_once(':') {
Some((s, r)) => (s, Some(r)),
None => (volume, None),
};
if !source.starts_with('/') {
return volume.to_string();
}
let mut normalised = String::from("/");
for segment in source
.split('/')
.filter(|seg| !seg.is_empty() && *seg != ".")
{
if normalised.len() > 1 {
normalised.push('/');
}
normalised.push_str(segment);
}
match rest {
Some(r) => format!("{normalised}:{r}"),
None => normalised,
}
}
fn destination_is_host_alias(volume: &str) -> bool {
let Some((_source, rest)) = volume.split_once(':') else {
return false;
};
let destination = rest.split(':').next().unwrap_or(rest);
destination == "/host" || destination.starts_with("/host/")
}
pub(super) fn volume_entry_string(value: &serde_yaml::Value) -> Option<String> {
match value {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Mapping(map) => {
let kind = map
.get(serde_yaml::Value::String("type".to_string()))
.and_then(serde_yaml::Value::as_str);
if matches!(kind, Some(other) if other != "bind") {
return None;
}
let source = map
.get(serde_yaml::Value::String("source".to_string()))
.and_then(serde_yaml::Value::as_str)?;
let target = map
.get(serde_yaml::Value::String("target".to_string()))
.and_then(serde_yaml::Value::as_str);
Some(match target {
Some(target) => format!("{source}:{target}"),
None => source.to_string(),
})
}
_ => None,
}
}
pub(super) fn env_file_has_real_paths(value: &serde_yaml::Value) -> bool {
match value {
serde_yaml::Value::String(s) => !s.trim().is_empty(),
serde_yaml::Value::Sequence(seq) => seq
.iter()
.any(|item| item.as_str().is_some_and(|s| !s.trim().is_empty())),
_ => false,
}
}
pub(super) fn render_env_file(value: &serde_yaml::Value) -> String {
match value {
serde_yaml::Value::String(s) => s.trim().to_string(),
serde_yaml::Value::Sequence(seq) => seq
.iter()
.filter_map(|item| item.as_str().map(str::trim).filter(|s| !s.is_empty()))
.collect::<Vec<_>>()
.join(", "),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_root_path_anchors_at_path_or_colon_boundary() {
assert!(matches_root_path("/root", "/root"));
assert!(matches_root_path("/root/.ssh", "/root"));
assert!(matches_root_path("/root:/k", "/root"));
assert!(matches_root_path("/root/.ssh:/k", "/root"));
assert!(!matches_root_path("/rootfs", "/root"));
assert!(!matches_root_path("/rootfs:/data", "/root"));
assert!(!matches_root_path("/root_bak/x:/y", "/root"));
assert!(matches_root_path("/proc:/proc", "/proc"));
assert!(matches_root_path("/proc/1:/p1", "/proc"));
assert!(!matches_root_path("/processed:/log", "/proc"));
assert!(!matches_root_path("/proc-tools:/x", "/proc"));
assert!(matches_root_path("/sys/kernel:/k", "/sys"));
assert!(!matches_root_path("/system:/sys", "/sys"));
assert!(!matches_root_path("/sysv:/x", "/sys"));
}
#[test]
fn is_sensitive_host_volume_flags_bare_anonymous_docker_socket_mount() {
assert!(is_sensitive_host_volume("/var/run/docker.sock"));
assert!(is_sensitive_host_volume("/var/run/docker.sock:/sock"));
assert!(is_sensitive_host_volume(
"/var/run/docker.sock:/var/run/docker.sock:ro"
));
assert!(!is_sensitive_host_volume("/var/run/docker.sockd"));
assert!(!is_sensitive_host_volume("/var/run/docker.sock-bak"));
}
#[test]
fn is_sensitive_host_volume_rejects_root_stem_lookalikes_bare() {
assert!(!is_sensitive_host_volume("/rootfs"));
assert!(!is_sensitive_host_volume("/rootkit"));
assert!(!is_sensitive_host_volume("/processed"));
assert!(!is_sensitive_host_volume("/sysv"));
}
#[test]
fn destination_is_host_alias_requires_exact_or_subpath_boundary() {
assert!(destination_is_host_alias("./data:/host"));
assert!(destination_is_host_alias("./data:/host:ro"));
assert!(destination_is_host_alias("/srv:/host:rw"));
assert!(destination_is_host_alias("./data:/host/etc"));
assert!(destination_is_host_alias("./data:/host/data:ro"));
assert!(!destination_is_host_alias("./data:/hostname"));
assert!(!destination_is_host_alias("./data:/host-backup"));
assert!(!destination_is_host_alias("./data:/hostpath"));
assert!(!destination_is_host_alias("./data:/host_data:ro"));
assert!(!destination_is_host_alias("/data"));
}
#[test]
fn is_sensitive_host_volume_rejects_host_alias_lookalikes() {
assert!(!is_sensitive_host_volume("./data:/hostname"));
assert!(!is_sensitive_host_volume("./data:/host-backup"));
assert!(!is_sensitive_host_volume("./data:/hostpath"));
assert!(is_sensitive_host_volume("./data:/host"));
assert!(is_sensitive_host_volume("./data:/host/etc"));
}
#[test]
fn volume_entry_string_returns_string_form_for_short_syntax() {
let value = serde_yaml::Value::String("/etc:/host-etc".to_string());
assert_eq!(
volume_entry_string(&value).as_deref(),
Some("/etc:/host-etc"),
);
}
#[test]
fn volume_entry_string_synthesises_source_target_for_long_bind_syntax() {
let yaml = "type: bind\nsource: /var/run/docker.sock\ntarget: /sock\n";
let value: serde_yaml::Value = serde_yaml::from_str(yaml).expect("valid yaml");
assert_eq!(
volume_entry_string(&value).as_deref(),
Some("/var/run/docker.sock:/sock"),
);
}
#[test]
fn volume_entry_string_accepts_long_syntax_without_explicit_type() {
let yaml = "source: /etc\ntarget: /host-etc\n";
let value: serde_yaml::Value = serde_yaml::from_str(yaml).expect("valid yaml");
assert_eq!(
volume_entry_string(&value).as_deref(),
Some("/etc:/host-etc"),
);
}
#[test]
fn volume_entry_string_skips_non_bind_long_syntax() {
for kind in ["volume", "tmpfs", "npipe", "cluster"] {
let yaml = format!("type: {kind}\nsource: db-data\ntarget: /var/lib/db\n");
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("valid yaml");
assert!(
volume_entry_string(&value).is_none(),
"type={kind} must yield None; got {:?}",
volume_entry_string(&value),
);
}
}
#[test]
fn is_sensitive_host_volume_flags_lexically_aliased_anonymous_socket_mount() {
assert!(is_sensitive_host_volume("//var/run/docker.sock"));
assert!(is_sensitive_host_volume("/./var/run/docker.sock"));
assert!(is_sensitive_host_volume("/var//run/docker.sock"));
assert!(is_sensitive_host_volume("//./var/run/docker.sock"));
assert!(is_sensitive_host_volume("//etc/passwd:/k"));
assert!(is_sensitive_host_volume("/./etc/shadow:/k"));
assert!(is_sensitive_host_volume("//root/.ssh"));
assert!(is_sensitive_host_volume("/./root/.ssh/authorized_keys"));
}
#[test]
fn normalise_volume_for_classification_only_rewrites_absolute_source() {
assert_eq!(
normalise_volume_for_classification("//var/run/docker.sock"),
"/var/run/docker.sock"
);
assert_eq!(
normalise_volume_for_classification("/./etc:/host-etc:ro"),
"/etc:/host-etc:ro"
);
assert_eq!(
normalise_volume_for_classification("./data:/data"),
"./data:/data"
);
assert_eq!(
normalise_volume_for_classification("db-data:/var/lib/db"),
"db-data:/var/lib/db"
);
assert_eq!(
normalise_volume_for_classification("/foo/../bar"),
"/foo/../bar"
);
}
#[test]
fn long_syntax_bind_mount_of_docker_socket_is_classified_as_sensitive() {
let yaml = "type: bind\nsource: /var/run/docker.sock\ntarget: /var/run/docker.sock\n";
let value: serde_yaml::Value = serde_yaml::from_str(yaml).expect("valid yaml");
let entry = volume_entry_string(&value).expect("bind volume must yield a string");
assert!(
is_sensitive_host_volume(&entry),
"long-syntax docker-socket bind mount must be classified as sensitive; got entry={entry:?}",
);
}
}