use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const JOB_LABEL: &str = "runner-manager.job";
pub const CONFIG_FILE: &str = "docker-policy.json";
pub const LOG_DIR: &str = "docker-policy";
pub const LOG_FILE: &str = "violations.jsonl";
const LOG_ROTATE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
Off,
#[default]
Audit,
Enforce,
}
impl Mode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Audit => "audit",
Self::Enforce => "enforce",
}
}
}
impl std::str::FromStr for Mode {
type Err = String;
fn from_str(text: &str) -> Result<Self, Self::Err> {
[Self::Off, Self::Audit, Self::Enforce]
.into_iter()
.find(|mode| mode.as_str() == text)
.ok_or_else(|| format!("expected off, audit or enforce, not {text}"))
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub mode: Mode,
#[serde(default)]
pub allow: Vec<PathBuf>,
}
impl Config {
pub fn load(path: &Path) -> io::Result<Self> {
runner_manager_platform::settings_file::load(path)
}
pub fn save(&self, path: &Path) -> io::Result<()> {
runner_manager_platform::settings_file::save(path, self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Context {
pub mode: Mode,
pub allowed: Vec<PathBuf>,
#[serde(default)]
pub protected: Vec<PathBuf>,
pub log: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
Allowed,
Outside,
Protected,
}
impl Context {
#[must_use]
pub fn records(&self, violation: &Violation) -> bool {
self.mode != Mode::Off || violation.rule == PROTECTED_PATH
}
#[must_use]
pub fn refuses(&self, violation: &Violation) -> bool {
self.mode == Mode::Enforce || violation.rule == PROTECTED_PATH
}
#[must_use]
pub fn access(&self, source: &Path) -> Access {
let Some(resolved) = (source.is_absolute()
&& !source
.components()
.any(|component| matches!(component, Component::ParentDir)))
.then(|| resolve(source))
.flatten() else {
return Access::Outside;
};
if self
.protected
.iter()
.any(|protected| resolved.starts_with(protected) || protected.starts_with(&resolved))
{
Access::Protected
} else if self.allowed.iter().any(|root| resolved.starts_with(root)) {
Access::Allowed
} else {
Access::Outside
}
}
}
const PROTECTED_PATH: &str = "protected_path";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Violation {
pub rule: String,
pub detail: String,
}
impl Violation {
pub(crate) fn new(rule: &str, detail: impl Into<String>) -> Self {
let mut detail = detail.into();
if detail.len() > 300 {
let mut end = 300;
while !detail.is_char_boundary(end) {
end -= 1;
}
detail.truncate(end);
}
Self {
rule: rule.to_owned(),
detail,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
pub at: String,
pub attempt: String,
pub repository: String,
pub rule: String,
pub detail: String,
pub refused: bool,
}
pub fn record(path: &Path, records: &[Record]) -> io::Result<()> {
use std::io::Write as _;
if records.is_empty() {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
if fs::metadata(path).is_ok_and(|metadata| metadata.len() > LOG_ROTATE_BYTES) {
let mut rotated = path.as_os_str().to_owned();
rotated.push(".1");
fs::rename(path, rotated)?;
}
let mut options = fs::OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let mut file = options.open(path)?;
for record in records {
let mut line = serde_json::to_vec(record).map_err(super::invalid)?;
line.push(b'\n');
file.write_all(&line)?;
}
Ok(())
}
#[must_use]
pub fn read_records(path: &Path) -> Vec<Record> {
let mut rotated = path.as_os_str().to_owned();
rotated.push(".1");
[PathBuf::from(rotated), path.to_path_buf()]
.iter()
.filter_map(|path| fs::read_to_string(path).ok())
.flat_map(|text| {
text.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect::<Vec<_>>()
})
.collect()
}
#[must_use]
pub fn unversioned(endpoint: &str) -> &str {
let rest = endpoint.strip_prefix('/').unwrap_or(endpoint);
match rest.split_once('/') {
Some((version, tail))
if version.len() > 1
&& version.starts_with('v')
&& version[1..].chars().all(|c| c.is_ascii_digit() || c == '.') =>
{
&endpoint[endpoint.len() - tail.len() - 1..]
}
_ => endpoint,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target<'a> {
Container(&'a str),
Exec(&'a str),
None,
}
#[must_use]
pub fn target(endpoint: &str) -> Target<'_> {
let mut parts = endpoint.trim_start_matches('/').split('/');
match (parts.next(), parts.next()) {
(Some("containers"), Some(id)) if !matches!(id, "" | "create" | "json" | "prune") => {
Target::Container(id)
}
(Some("exec"), Some(id)) if !id.is_empty() => Target::Exec(id),
_ => Target::None,
}
}
#[must_use]
pub fn check_endpoint(method: &str, endpoint: &str, query: &str) -> Vec<Violation> {
let mut violations = Vec::new();
let first = endpoint
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("");
if matches!(
first,
"plugins" | "swarm" | "services" | "secrets" | "configs" | "nodes" | "tasks"
) {
violations.push(Violation::new(
"host_wide_api",
format!("{method} {endpoint} acts on the engine itself"),
));
}
if first == "libpod" {
violations.push(Violation::new(
"unchecked_api",
format!("{method} {endpoint} is Podman's native API, which this policy cannot check"),
));
}
if method == "POST"
&& matches!(
endpoint,
"/containers/prune"
| "/volumes/prune"
| "/networks/prune"
| "/images/prune"
| "/build/prune"
)
{
violations.push(Violation::new(
"host_wide_prune",
format!("{endpoint} removes what other jobs and the host created"),
));
}
if method == "POST"
&& endpoint == "/build"
&& query
.split('&')
.any(|field| field.eq_ignore_ascii_case("networkmode=host"))
{
violations.push(Violation::new(
"host_network",
"a build on the host's network",
));
}
violations
}
#[must_use]
pub fn check_container_create(body: &Value, allowed: &dyn Fn(&Path) -> Access) -> Vec<Violation> {
let mut violations = Vec::new();
let host = body.get("HostConfig").unwrap_or(&Value::Null);
if host.get("Privileged").and_then(Value::as_bool) == Some(true) {
violations.push(Violation::new("privileged", "a privileged container"));
}
for (key, rule) in [
("NetworkMode", "host_network"),
("PidMode", "host_pid"),
("IpcMode", "host_ipc"),
("UTSMode", "host_uts"),
("UsernsMode", "host_userns"),
("CgroupnsMode", "host_cgroupns"),
] {
if host.get(key).and_then(Value::as_str) == Some("host") {
violations.push(Violation::new(rule, format!("{key}=host")));
}
}
if let Some(added) = host.get("CapAdd").and_then(Value::as_array)
&& !added.is_empty()
{
let names = added
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(",");
violations.push(Violation::new(
"added_capability",
format!("CapAdd={names}"),
));
}
for key in ["Devices", "DeviceCgroupRules", "DeviceRequests"] {
if host
.get(key)
.and_then(Value::as_array)
.is_some_and(|devices| !devices.is_empty())
{
violations.push(Violation::new("host_device", format!("{key} is set")));
}
}
if let Some(options) = host.get("SecurityOpt").and_then(Value::as_array) {
for option in options.iter().filter_map(Value::as_str) {
let lowered = option.to_ascii_lowercase();
if lowered.contains("unconfined")
|| lowered == "label=disable"
|| lowered == "label:disable"
|| lowered.starts_with("no-new-privileges=false")
|| lowered.starts_with("no-new-privileges:false")
{
violations.push(Violation::new(
"weakened_security",
format!("SecurityOpt={option}"),
));
}
}
}
for key in ["MaskedPaths", "ReadonlyPaths"] {
if host.get(key).is_some_and(Value::is_array) {
violations.push(Violation::new(
"weakened_security",
format!("{key} replaces the engine's defaults"),
));
}
}
if let Some(binds) = host.get("Binds").and_then(Value::as_array) {
for bind in binds.iter().filter_map(Value::as_str) {
let source = bind.split(':').next().unwrap_or("");
if source.starts_with('/') {
check_host_path(Path::new(source), allowed, &mut violations);
}
}
}
if let Some(mounts) = host.get("Mounts").and_then(Value::as_array) {
for mount in mounts {
match mount.get("Type").and_then(Value::as_str) {
Some("bind") => {
if let Some(source) = mount.get("Source").and_then(Value::as_str) {
check_host_path(Path::new(source), allowed, &mut violations);
}
}
Some("volume") => {
if let Some(device) = bound_device(
mount
.pointer("/VolumeOptions/DriverConfig/Options")
.unwrap_or(&Value::Null),
) {
check_host_path(Path::new(device), allowed, &mut violations);
}
}
_ => {}
}
}
}
violations
}
#[must_use]
pub fn referenced_containers(body: &Value) -> Vec<String> {
let host = body.get("HostConfig").unwrap_or(&Value::Null);
let mut referenced = Vec::new();
for key in ["NetworkMode", "PidMode", "IpcMode"] {
if let Some(other) = host
.get(key)
.and_then(Value::as_str)
.and_then(|mode| mode.strip_prefix("container:"))
{
referenced.push(other.to_owned());
}
}
if let Some(sources) = host.get("VolumesFrom").and_then(Value::as_array) {
referenced.extend(
sources
.iter()
.filter_map(Value::as_str)
.map(|source| source.split(':').next().unwrap_or(source).to_owned()),
);
}
referenced
}
#[must_use]
pub fn check_exec_create(body: &Value) -> Vec<Violation> {
if body.get("Privileged").and_then(Value::as_bool) == Some(true) {
vec![Violation::new("privileged", "a privileged exec")]
} else {
Vec::new()
}
}
#[must_use]
pub fn check_volume_create(body: &Value, allowed: &dyn Fn(&Path) -> Access) -> Vec<Violation> {
let mut violations = Vec::new();
if let Some(device) = bound_device(body.get("DriverOpts").unwrap_or(&Value::Null)) {
check_host_path(Path::new(device), allowed, &mut violations);
}
violations
}
#[must_use]
pub fn check_network_create(body: &Value) -> Vec<Violation> {
match body.get("Driver").and_then(Value::as_str) {
Some(driver @ ("macvlan" | "ipvlan")) => vec![Violation::new(
"host_network",
format!("a {driver} network on the host's LAN"),
)],
_ => Vec::new(),
}
}
fn bound_device(options: &Value) -> Option<&str> {
options
.get("device")
.and_then(Value::as_str)
.filter(|device| device.starts_with('/'))
}
const ENGINE_SOCKETS: [&str; 2] = ["/var/run/docker.sock", "/run/docker.sock"];
pub fn redirect_engine_socket(body: &mut Value, proxy: &Path) -> usize {
let is_engine = |source: &str| ENGINE_SOCKETS.contains(&source);
let proxy = proxy.display().to_string();
let mut changed = 0;
let Some(host) = body.get_mut("HostConfig").and_then(Value::as_object_mut) else {
return 0;
};
if let Some(binds) = host.get_mut("Binds").and_then(Value::as_array_mut) {
for bind in binds {
let Some(text) = bind.as_str() else { continue };
let (source, rest) = text.split_once(':').unwrap_or((text, ""));
if is_engine(source) {
*bind = Value::String(if rest.is_empty() {
proxy.clone()
} else {
format!("{proxy}:{rest}")
});
changed += 1;
}
}
}
if let Some(mounts) = host.get_mut("Mounts").and_then(Value::as_array_mut) {
for mount in mounts {
let bind = mount.get("Type").and_then(Value::as_str) == Some("bind");
if let Some(source) = mount.get_mut("Source")
&& bind
&& source.as_str().is_some_and(is_engine)
{
*source = Value::String(proxy.clone());
changed += 1;
}
}
}
changed
}
fn check_host_path(
source: &Path,
allowed: &dyn Fn(&Path) -> Access,
violations: &mut Vec<Violation>,
) {
if ENGINE_SOCKETS
.iter()
.any(|socket| source == Path::new(socket))
{
violations.push(Violation::new(
"engine_socket",
format!("{} is the Docker engine itself", source.display()),
));
return;
}
match allowed(source) {
Access::Allowed => {}
Access::Protected => violations.push(Violation::new(
PROTECTED_PATH,
format!("{} is a protected host path", source.display()),
)),
Access::Outside => violations.push(Violation::new(
"host_path",
format!("{} is outside the job's own directories", source.display()),
)),
}
}
#[must_use]
pub fn resolve(path: &Path) -> Option<PathBuf> {
let mut existing = path.to_path_buf();
let mut rest = Vec::new();
loop {
match fs::canonicalize(&existing) {
Ok(mut resolved) => {
for component in rest.iter().rev() {
resolved.push(component);
}
return Some(resolved);
}
Err(_) => {
rest.push(existing.file_name()?.to_owned());
existing = existing.parent()?.to_path_buf();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn rules(violations: &[Violation]) -> Vec<&str> {
violations
.iter()
.map(|violation| violation.rule.as_str())
.collect()
}
#[test]
fn the_version_prefix_is_dropped_and_targets_are_found() {
assert_eq!(
unversioned("/v1.47/containers/abc/json"),
"/containers/abc/json"
);
assert_eq!(unversioned("/containers/abc/json"), "/containers/abc/json");
assert_eq!(unversioned("/version"), "/version");
assert_eq!(target("/containers/abc/exec"), Target::Container("abc"));
assert_eq!(target("/containers/create"), Target::None);
assert_eq!(target("/containers/json"), Target::None);
assert_eq!(target("/exec/e1/start"), Target::Exec("e1"));
assert_eq!(target("/images/json"), Target::None);
}
#[test]
fn host_wide_endpoints_are_violations_and_ordinary_ones_are_not() {
assert_eq!(
rules(&check_endpoint("POST", "/swarm/init", "")),
["host_wide_api"]
);
assert_eq!(
rules(&check_endpoint("POST", "/plugins/pull", "")),
["host_wide_api"]
);
assert_eq!(
rules(&check_endpoint("POST", "/containers/prune", "")),
["host_wide_prune"]
);
assert_eq!(
rules(&check_endpoint("POST", "/build", "t=x&networkmode=host")),
["host_network"]
);
assert!(check_endpoint("POST", "/build", "t=x").is_empty());
assert_eq!(
rules(&check_endpoint("POST", "/images/prune", "")),
["host_wide_prune"]
);
assert_eq!(
rules(&check_endpoint("POST", "/libpod/containers/create", "")),
["unchecked_api"]
);
assert!(check_endpoint("GET", "/containers/json", "").is_empty());
assert!(check_endpoint("POST", "/images/create", "fromImage=x").is_empty());
}
#[test]
fn a_container_that_reaches_for_the_host_breaks_each_rule_it_touches() {
let body = json!({
"HostConfig": {
"Privileged": true,
"NetworkMode": "host",
"PidMode": "host",
"CapAdd": ["SYS_ADMIN"],
"Devices": [{"PathOnHost": "/dev/sda"}],
"SecurityOpt": ["seccomp=unconfined"],
"Binds": ["/etc:/host-etc:ro", "/var/run/docker.sock:/var/run/docker.sock", "named:/data"],
"Mounts": [{"Type": "bind", "Source": "/root", "Target": "/r"}]
}
});
let found = check_container_create(&body, &|_| Access::Outside);
assert_eq!(
rules(&found),
[
"privileged",
"host_network",
"host_pid",
"added_capability",
"host_device",
"weakened_security",
"host_path",
"engine_socket",
"host_path"
]
);
}
#[test]
fn an_ordinary_job_container_breaks_nothing() {
let body = json!({
"Image": "node:22",
"HostConfig": {
"NetworkMode": "bridge",
"Binds": ["/attempt/_work:/__w", "/attempt/externals:/__e:ro", "cache:/cache"],
"Mounts": [{"Type": "tmpfs", "Target": "/tmp"}]
}
});
let found = check_container_create(&body, &|path| {
if path.starts_with("/attempt") {
Access::Allowed
} else {
Access::Outside
}
});
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn containers_a_new_one_joins_or_borrows_from_are_named() {
let body = json!({"HostConfig": {
"NetworkMode": "container:db",
"PidMode": "host",
"VolumesFrom": ["data:ro", "cache"]
}});
assert_eq!(referenced_containers(&body), ["db", "data", "cache"]);
}
#[test]
fn volumes_and_networks_that_bind_the_host_are_violations() {
let bound = json!({"Driver": "local", "DriverOpts": {"type": "none", "o": "bind", "device": "/srv/secret"}});
assert_eq!(
rules(&check_volume_create(&bound, &|_| Access::Outside)),
["host_path"]
);
assert!(check_volume_create(&json!({"Driver": "local"}), &|_| Access::Outside).is_empty());
let disk =
json!({"Driver": "local", "DriverOpts": {"type": "ext4", "device": "/dev/sda1"}});
assert_eq!(
rules(&check_volume_create(&disk, &|_| Access::Outside)),
["host_path"],
"a host block device is mounted without any bind option"
);
assert_eq!(
rules(&check_container_create(
&json!({"HostConfig": {"MaskedPaths": [], "ReadonlyPaths": null}}),
&|_| Access::Outside
)),
["weakened_security"]
);
assert_eq!(
rules(&check_network_create(&json!({"Driver": "macvlan"}))),
["host_network"]
);
assert!(check_network_create(&json!({"Driver": "bridge"})).is_empty());
assert_eq!(
rules(&check_exec_create(&json!({"Privileged": true}))),
["privileged"]
);
assert!(check_exec_create(&json!({"Cmd": ["sh"]})).is_empty());
}
#[cfg(unix)]
#[test]
fn a_link_inside_an_allowed_directory_does_not_carry_a_mount_outside_it() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let attempt = root.path().join("attempt");
fs::create_dir_all(attempt.join("_work")).unwrap();
std::os::unix::fs::symlink(outside.path(), attempt.join("escape")).unwrap();
let context = Context {
mode: Mode::Enforce,
allowed: vec![fs::canonicalize(&attempt).unwrap()],
protected: vec![],
log: root.path().join("log"),
};
let inside = |path: &Path| context.access(path) == Access::Allowed;
assert!(inside(&attempt.join("_work")));
assert!(inside(&attempt.join("_work/not/yet/created")));
assert!(!inside(&attempt.join("escape")));
assert!(!inside(&attempt.join("escape/inner")));
assert!(!inside(&attempt.join("_work/../../etc")));
assert!(!inside(Path::new("relative")));
}
#[cfg(unix)]
#[test]
fn a_protected_path_is_refused_even_inside_an_allowed_directory() {
let root = tempfile::tempdir().unwrap();
let shared = root.path().join("shared");
fs::create_dir_all(shared.join("keys")).unwrap();
fs::create_dir_all(shared.join("data")).unwrap();
let context = Context {
mode: Mode::Enforce,
allowed: vec![fs::canonicalize(&shared).unwrap()],
protected: vec![fs::canonicalize(shared.join("keys")).unwrap()],
log: root.path().join("log"),
};
assert_eq!(context.access(&shared.join("data")), Access::Allowed);
assert_eq!(context.access(&shared.join("keys/id")), Access::Protected);
assert_eq!(
context.access(&shared),
Access::Protected,
"a parent exposes it too"
);
assert_eq!(context.access(Path::new("/etc")), Access::Outside);
let body =
json!({"HostConfig": {"Binds": [format!("{}:/k", shared.join("keys").display())]}});
assert_eq!(
rules(&check_container_create(&body, &|path| context.access(path))),
["protected_path"]
);
}
#[test]
fn a_protected_path_is_refused_in_every_mode_and_the_rest_only_when_enforced() {
let protected = Violation::new(PROTECTED_PATH, "/srv/keys");
let privileged = Violation::new("privileged", "a privileged container");
for (mode, recorded, refused) in [
(Mode::Off, false, false),
(Mode::Audit, true, false),
(Mode::Enforce, true, true),
] {
let context = Context {
mode,
allowed: vec![],
protected: vec![],
log: PathBuf::from("log"),
};
assert!(context.records(&protected) && context.refuses(&protected));
assert_eq!(context.records(&privileged), recorded, "{mode:?}");
assert_eq!(context.refuses(&privileged), refused, "{mode:?}");
}
}
#[test]
fn the_engine_socket_a_job_container_is_given_becomes_the_proxy() {
let mut body = json!({"HostConfig": {
"Binds": ["/var/run/docker.sock:/var/run/docker.sock", "/attempt/_work:/__w"],
"Mounts": [{"Type": "bind", "Source": "/run/docker.sock", "Target": "/d.sock"}]
}});
let proxy = Path::new("/attempt/.docker-proxy/s");
assert_eq!(redirect_engine_socket(&mut body, proxy), 2);
assert_eq!(
body.pointer("/HostConfig/Binds/0").unwrap(),
"/attempt/.docker-proxy/s:/var/run/docker.sock"
);
assert_eq!(
body.pointer("/HostConfig/Binds/1").unwrap(),
"/attempt/_work:/__w"
);
assert_eq!(
body.pointer("/HostConfig/Mounts/0/Source").unwrap(),
"/attempt/.docker-proxy/s"
);
let allowed = |path: &Path| {
if path == proxy || path.starts_with("/attempt") {
Access::Allowed
} else {
Access::Outside
}
};
assert!(check_container_create(&body, &allowed).is_empty());
}
#[test]
fn records_round_trip_through_a_rotating_log() {
let directory = tempfile::tempdir().unwrap();
let log = directory.path().join(LOG_DIR).join(LOG_FILE);
let entry = Record {
at: "2026-11-10T00:00:00Z".into(),
attempt: "a".into(),
repository: "o/r".into(),
rule: "privileged".into(),
detail: "a privileged container".into(),
refused: false,
};
record(&log, std::slice::from_ref(&entry)).unwrap();
record(&log, std::slice::from_ref(&entry)).unwrap();
assert_eq!(read_records(&log), [entry.clone(), entry]);
}
}