Skip to main content

runner_manager_agent/docker_cleanup/
policy.rs

1//! What a job may ask of the Docker engine through its proxy.
2//!
3//! Access to a Docker engine is access to `root`: a container can mount any
4//! host directory, join the host's network or process namespaces, or run
5//! privileged. Every native Linux job reaches the engine through its own
6//! proxy, so the proxy is where those requests are seen. In [`Mode::Audit`]
7//! each one is recorded and still forwarded; in [`Mode::Enforce`] it is
8//! refused with the rule it broke.
9//!
10//! A mount of a path the operator protected (`host protect-path`) is refused
11//! in every mode, `off` included: the operator asked for that path to be out
12//! of every job's reach, and the engine runs outside the service's sandbox.
13//!
14//! The rules are about the host and about other jobs, never about what a job
15//! does inside its own containers:
16//!
17//! - a container may bind-mount only the job's own attempt directory, its
18//!   dependency caches, and paths the operator allowed, each judged after the
19//!   host resolves its links;
20//! - no privileged container or exec, no added capability or device, no
21//!   weakened security profile, and no host network, process, IPC, UTS, user
22//!   or cgroup namespace;
23//! - no volume bound to a host path and no network attached to the host's LAN;
24//! - no request about a container another job, or the host, started;
25//! - no host-wide prune, swarm, plugin or secret API.
26
27use std::fs;
28use std::io;
29use std::path::{Component, Path, PathBuf};
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34/// The label the proxy puts on every container a job creates, naming the job's
35/// attempt, so a later request can tell the job's containers from others.
36pub const JOB_LABEL: &str = "runner-manager.job";
37
38/// The policy's settings, in the configuration directory.
39pub const CONFIG_FILE: &str = "docker-policy.json";
40
41/// Where violations are recorded, under the state directory.
42pub const LOG_DIR: &str = "docker-policy";
43/// The violation log in [`LOG_DIR`], one JSON object per line.
44pub const LOG_FILE: &str = "violations.jsonl";
45/// The size at which the log is moved to `violations.jsonl.1`.
46const LOG_ROTATE_BYTES: u64 = 1024 * 1024;
47
48/// What the proxy does with a request that breaks a rule.
49#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Mode {
52    /// No rule is checked.
53    Off,
54    /// Each violation is recorded, and the request still goes to the engine.
55    #[default]
56    Audit,
57    /// A request with a violation is refused, and the violation recorded.
58    Enforce,
59}
60
61impl Mode {
62    /// The mode as `host docker-policy set-mode` spells it.
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Off => "off",
67            Self::Audit => "audit",
68            Self::Enforce => "enforce",
69        }
70    }
71}
72
73impl std::str::FromStr for Mode {
74    type Err = String;
75
76    fn from_str(text: &str) -> Result<Self, Self::Err> {
77        [Self::Off, Self::Audit, Self::Enforce]
78            .into_iter()
79            .find(|mode| mode.as_str() == text)
80            .ok_or_else(|| format!("expected off, audit or enforce, not {text}"))
81    }
82}
83
84/// The operator's settings.
85#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Config {
88    #[serde(default)]
89    pub mode: Mode,
90    /// Host directories every job may bind-mount besides its own.
91    #[serde(default)]
92    pub allow: Vec<PathBuf>,
93}
94
95impl Config {
96    /// The settings at `path`, or the defaults when there is no file.
97    ///
98    /// # Errors
99    ///
100    /// When the file cannot be read or does not parse.
101    pub fn load(path: &Path) -> io::Result<Self> {
102        runner_manager_platform::settings_file::load(path)
103    }
104
105    /// Writes the settings to `path`.
106    ///
107    /// # Errors
108    ///
109    /// When the file cannot be written.
110    pub fn save(&self, path: &Path) -> io::Result<()> {
111        runner_manager_platform::settings_file::save(path, self)
112    }
113}
114
115/// What one attempt's proxy enforces, fixed when the attempt launches.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Context {
118    pub mode: Mode,
119    /// The directories a container may bind-mount from, already resolved.
120    pub allowed: Vec<PathBuf>,
121    /// Host paths the operator protected (`host protect-path`), already
122    /// resolved: never mountable, even inside an allowed directory. The
123    /// service's own sandbox hides them, but the engine runs outside it.
124    #[serde(default)]
125    pub protected: Vec<PathBuf>,
126    /// The violation log.
127    pub log: PathBuf,
128}
129
130/// Whether a container may bind-mount a host path.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum Access {
133    Allowed,
134    /// Outside the job's own directories and the operator's allowed ones.
135    Outside,
136    /// A protected path, or one inside or above it.
137    Protected,
138}
139
140impl Context {
141    /// Whether `violation` is recorded: every one unless the policy is off,
142    /// and a protected path's always.
143    #[must_use]
144    pub fn records(&self, violation: &Violation) -> bool {
145        self.mode != Mode::Off || violation.rule == PROTECTED_PATH
146    }
147
148    /// Whether `violation` refuses its request: every one in
149    /// [`Mode::Enforce`], and a protected path's in every mode.
150    #[must_use]
151    pub fn refuses(&self, violation: &Violation) -> bool {
152        self.mode == Mode::Enforce || violation.rule == PROTECTED_PATH
153    }
154
155    /// Whether a container may bind-mount `source`.
156    ///
157    /// A path with `..` is refused outright. Otherwise the longest existing
158    /// prefix is resolved through every link on the host, so that a link the
159    /// job left in its own directory cannot point a mount anywhere else, and
160    /// the rest is appended as written: Docker creates a missing bind source
161    /// as a directory.
162    #[must_use]
163    pub fn access(&self, source: &Path) -> Access {
164        let Some(resolved) = (source.is_absolute()
165            && !source
166                .components()
167                .any(|component| matches!(component, Component::ParentDir)))
168        .then(|| resolve(source))
169        .flatten() else {
170            return Access::Outside;
171        };
172        if self
173            .protected
174            .iter()
175            .any(|protected| resolved.starts_with(protected) || protected.starts_with(&resolved))
176        {
177            Access::Protected
178        } else if self.allowed.iter().any(|root| resolved.starts_with(root)) {
179            Access::Allowed
180        } else {
181            Access::Outside
182        }
183    }
184}
185
186/// The rule a mount of a protected path breaks.
187const PROTECTED_PATH: &str = "protected_path";
188
189/// A rule a request broke.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Violation {
192    pub rule: String,
193    pub detail: String,
194}
195
196impl Violation {
197    pub(crate) fn new(rule: &str, detail: impl Into<String>) -> Self {
198        let mut detail = detail.into();
199        if detail.len() > 300 {
200            let mut end = 300;
201            while !detail.is_char_boundary(end) {
202                end -= 1;
203            }
204            detail.truncate(end);
205        }
206        Self {
207            rule: rule.to_owned(),
208            detail,
209        }
210    }
211}
212
213/// One recorded violation.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct Record {
216    pub at: String,
217    pub attempt: String,
218    pub repository: String,
219    pub rule: String,
220    pub detail: String,
221    /// Whether the request was refused.
222    pub refused: bool,
223}
224
225/// Appends `records` to the log at `path`, moving a full log aside first.
226///
227/// # Errors
228///
229/// When the log cannot be written.
230pub fn record(path: &Path, records: &[Record]) -> io::Result<()> {
231    use std::io::Write as _;
232    if records.is_empty() {
233        return Ok(());
234    }
235    if let Some(parent) = path.parent() {
236        fs::create_dir_all(parent)?;
237    }
238    if fs::metadata(path).is_ok_and(|metadata| metadata.len() > LOG_ROTATE_BYTES) {
239        let mut rotated = path.as_os_str().to_owned();
240        rotated.push(".1");
241        fs::rename(path, rotated)?;
242    }
243    let mut options = fs::OpenOptions::new();
244    options.create(true).append(true);
245    #[cfg(unix)]
246    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
247    let mut file = options.open(path)?;
248    for record in records {
249        let mut line = serde_json::to_vec(record).map_err(super::invalid)?;
250        line.push(b'\n');
251        file.write_all(&line)?;
252    }
253    Ok(())
254}
255
256/// Every record in the log at `path` and its rotated predecessor, oldest
257/// first. A line that does not parse is skipped.
258#[must_use]
259pub fn read_records(path: &Path) -> Vec<Record> {
260    let mut rotated = path.as_os_str().to_owned();
261    rotated.push(".1");
262    [PathBuf::from(rotated), path.to_path_buf()]
263        .iter()
264        .filter_map(|path| fs::read_to_string(path).ok())
265        .flat_map(|text| {
266            text.lines()
267                .filter_map(|line| serde_json::from_str(line).ok())
268                .collect::<Vec<_>>()
269        })
270        .collect()
271}
272
273/// The API path without its version prefix: `/v1.47/containers/x/json`
274/// becomes `/containers/x/json`.
275#[must_use]
276pub fn unversioned(endpoint: &str) -> &str {
277    let rest = endpoint.strip_prefix('/').unwrap_or(endpoint);
278    match rest.split_once('/') {
279        Some((version, tail))
280            if version.len() > 1
281                && version.starts_with('v')
282                && version[1..].chars().all(|c| c.is_ascii_digit() || c == '.') =>
283        {
284            &endpoint[endpoint.len() - tail.len() - 1..]
285        }
286        _ => endpoint,
287    }
288}
289
290/// What a request acts on, when it is one container or exec instance.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum Target<'a> {
293    Container(&'a str),
294    Exec(&'a str),
295    None,
296}
297
298/// The container or exec instance an unversioned `endpoint` names.
299#[must_use]
300pub fn target(endpoint: &str) -> Target<'_> {
301    let mut parts = endpoint.trim_start_matches('/').split('/');
302    match (parts.next(), parts.next()) {
303        (Some("containers"), Some(id)) if !matches!(id, "" | "create" | "json" | "prune") => {
304            Target::Container(id)
305        }
306        (Some("exec"), Some(id)) if !id.is_empty() => Target::Exec(id),
307        _ => Target::None,
308    }
309}
310
311/// Rules an unversioned `endpoint` breaks by what it is, whatever its body.
312#[must_use]
313pub fn check_endpoint(method: &str, endpoint: &str, query: &str) -> Vec<Violation> {
314    let mut violations = Vec::new();
315    let first = endpoint
316        .trim_start_matches('/')
317        .split('/')
318        .next()
319        .unwrap_or("");
320    if matches!(
321        first,
322        "plugins" | "swarm" | "services" | "secrets" | "configs" | "nodes" | "tasks"
323    ) {
324        violations.push(Violation::new(
325            "host_wide_api",
326            format!("{method} {endpoint} acts on the engine itself"),
327        ));
328    }
329    // Podman's native API takes the same requests in another shape, which
330    // none of the checks below read: a container created through it would
331    // pass every rule unseen.
332    if first == "libpod" {
333        violations.push(Violation::new(
334            "unchecked_api",
335            format!("{method} {endpoint} is Podman's native API, which this policy cannot check"),
336        ));
337    }
338    if method == "POST"
339        && matches!(
340            endpoint,
341            "/containers/prune"
342                | "/volumes/prune"
343                | "/networks/prune"
344                | "/images/prune"
345                | "/build/prune"
346        )
347    {
348        violations.push(Violation::new(
349            "host_wide_prune",
350            format!("{endpoint} removes what other jobs and the host created"),
351        ));
352    }
353    if method == "POST"
354        && endpoint == "/build"
355        && query
356            .split('&')
357            .any(|field| field.eq_ignore_ascii_case("networkmode=host"))
358    {
359        violations.push(Violation::new(
360            "host_network",
361            "a build on the host's network",
362        ));
363    }
364    violations
365}
366
367/// Rules a container-create `body` breaks. `allowed` says whether a host path
368/// may be bind-mounted.
369#[must_use]
370pub fn check_container_create(body: &Value, allowed: &dyn Fn(&Path) -> Access) -> Vec<Violation> {
371    let mut violations = Vec::new();
372    let host = body.get("HostConfig").unwrap_or(&Value::Null);
373    if host.get("Privileged").and_then(Value::as_bool) == Some(true) {
374        violations.push(Violation::new("privileged", "a privileged container"));
375    }
376    for (key, rule) in [
377        ("NetworkMode", "host_network"),
378        ("PidMode", "host_pid"),
379        ("IpcMode", "host_ipc"),
380        ("UTSMode", "host_uts"),
381        ("UsernsMode", "host_userns"),
382        ("CgroupnsMode", "host_cgroupns"),
383    ] {
384        if host.get(key).and_then(Value::as_str) == Some("host") {
385            violations.push(Violation::new(rule, format!("{key}=host")));
386        }
387    }
388    if let Some(added) = host.get("CapAdd").and_then(Value::as_array)
389        && !added.is_empty()
390    {
391        let names = added
392            .iter()
393            .filter_map(Value::as_str)
394            .collect::<Vec<_>>()
395            .join(",");
396        violations.push(Violation::new(
397            "added_capability",
398            format!("CapAdd={names}"),
399        ));
400    }
401    for key in ["Devices", "DeviceCgroupRules", "DeviceRequests"] {
402        if host
403            .get(key)
404            .and_then(Value::as_array)
405            .is_some_and(|devices| !devices.is_empty())
406        {
407            violations.push(Violation::new("host_device", format!("{key} is set")));
408        }
409    }
410    if let Some(options) = host.get("SecurityOpt").and_then(Value::as_array) {
411        for option in options.iter().filter_map(Value::as_str) {
412            let lowered = option.to_ascii_lowercase();
413            if lowered.contains("unconfined")
414                || lowered == "label=disable"
415                || lowered == "label:disable"
416                || lowered.starts_with("no-new-privileges=false")
417                || lowered.starts_with("no-new-privileges:false")
418            {
419                violations.push(Violation::new(
420                    "weakened_security",
421                    format!("SecurityOpt={option}"),
422                ));
423            }
424        }
425    }
426    // An array, even an empty one, replaces the engine's defaults: what they
427    // mask or keep read-only in `/proc` and `/sys` is then exposed.
428    for key in ["MaskedPaths", "ReadonlyPaths"] {
429        if host.get(key).is_some_and(Value::is_array) {
430            violations.push(Violation::new(
431                "weakened_security",
432                format!("{key} replaces the engine's defaults"),
433            ));
434        }
435    }
436    if let Some(binds) = host.get("Binds").and_then(Value::as_array) {
437        for bind in binds.iter().filter_map(Value::as_str) {
438            let source = bind.split(':').next().unwrap_or("");
439            if source.starts_with('/') {
440                check_host_path(Path::new(source), allowed, &mut violations);
441            }
442        }
443    }
444    if let Some(mounts) = host.get("Mounts").and_then(Value::as_array) {
445        for mount in mounts {
446            match mount.get("Type").and_then(Value::as_str) {
447                Some("bind") => {
448                    if let Some(source) = mount.get("Source").and_then(Value::as_str) {
449                        check_host_path(Path::new(source), allowed, &mut violations);
450                    }
451                }
452                Some("volume") => {
453                    if let Some(device) = bound_device(
454                        mount
455                            .pointer("/VolumeOptions/DriverConfig/Options")
456                            .unwrap_or(&Value::Null),
457                    ) {
458                        check_host_path(Path::new(device), allowed, &mut violations);
459                    }
460                }
461                _ => {}
462            }
463        }
464    }
465    violations
466}
467
468/// The other containers a container-create `body` joins or borrows from:
469/// `container:<id>` namespaces and `VolumesFrom`. Whether each is the job's
470/// own is for the proxy to ask the engine.
471#[must_use]
472pub fn referenced_containers(body: &Value) -> Vec<String> {
473    let host = body.get("HostConfig").unwrap_or(&Value::Null);
474    let mut referenced = Vec::new();
475    for key in ["NetworkMode", "PidMode", "IpcMode"] {
476        if let Some(other) = host
477            .get(key)
478            .and_then(Value::as_str)
479            .and_then(|mode| mode.strip_prefix("container:"))
480        {
481            referenced.push(other.to_owned());
482        }
483    }
484    if let Some(sources) = host.get("VolumesFrom").and_then(Value::as_array) {
485        referenced.extend(
486            sources
487                .iter()
488                .filter_map(Value::as_str)
489                .map(|source| source.split(':').next().unwrap_or(source).to_owned()),
490        );
491    }
492    referenced
493}
494
495/// Rules an exec-create `body` breaks.
496#[must_use]
497pub fn check_exec_create(body: &Value) -> Vec<Violation> {
498    if body.get("Privileged").and_then(Value::as_bool) == Some(true) {
499        vec![Violation::new("privileged", "a privileged exec")]
500    } else {
501        Vec::new()
502    }
503}
504
505/// Rules a volume-create `body` breaks.
506#[must_use]
507pub fn check_volume_create(body: &Value, allowed: &dyn Fn(&Path) -> Access) -> Vec<Violation> {
508    let mut violations = Vec::new();
509    if let Some(device) = bound_device(body.get("DriverOpts").unwrap_or(&Value::Null)) {
510        check_host_path(Path::new(device), allowed, &mut violations);
511    }
512    violations
513}
514
515/// Rules a network-create `body` breaks.
516#[must_use]
517pub fn check_network_create(body: &Value) -> Vec<Violation> {
518    match body.get("Driver").and_then(Value::as_str) {
519        Some(driver @ ("macvlan" | "ipvlan")) => vec![Violation::new(
520            "host_network",
521            format!("a {driver} network on the host's LAN"),
522        )],
523        _ => Vec::new(),
524    }
525}
526
527/// The host path a `local` volume's options mount, if they mount one: a
528/// directory bound with `o=bind`, or a block device mounted as a filesystem
529/// (`type=ext4,device=/dev/sda1`), which needs no bind option at all.
530fn bound_device(options: &Value) -> Option<&str> {
531    options
532        .get("device")
533        .and_then(Value::as_str)
534        .filter(|device| device.starts_with('/'))
535}
536
537/// The engine sockets a container must never be handed: either one is the
538/// whole engine, with none of these rules.
539const ENGINE_SOCKETS: [&str; 2] = ["/var/run/docker.sock", "/run/docker.sock"];
540
541/// Points every bind of the engine's own socket in a container-create `body`
542/// at `proxy`, the job's proxy socket, and returns how many it changed.
543///
544/// The GitHub runner mounts `/var/run/docker.sock` into every job container
545/// (`container:` jobs and service containers) so steps can use Docker. The
546/// raw socket would let those steps reach the engine past this proxy and its
547/// rules; the proxy's own socket gives them the same API, through the proxy.
548pub fn redirect_engine_socket(body: &mut Value, proxy: &Path) -> usize {
549    let is_engine = |source: &str| ENGINE_SOCKETS.contains(&source);
550    let proxy = proxy.display().to_string();
551    let mut changed = 0;
552    let Some(host) = body.get_mut("HostConfig").and_then(Value::as_object_mut) else {
553        return 0;
554    };
555    if let Some(binds) = host.get_mut("Binds").and_then(Value::as_array_mut) {
556        for bind in binds {
557            let Some(text) = bind.as_str() else { continue };
558            let (source, rest) = text.split_once(':').unwrap_or((text, ""));
559            if is_engine(source) {
560                *bind = Value::String(if rest.is_empty() {
561                    proxy.clone()
562                } else {
563                    format!("{proxy}:{rest}")
564                });
565                changed += 1;
566            }
567        }
568    }
569    if let Some(mounts) = host.get_mut("Mounts").and_then(Value::as_array_mut) {
570        for mount in mounts {
571            let bind = mount.get("Type").and_then(Value::as_str) == Some("bind");
572            if let Some(source) = mount.get_mut("Source")
573                && bind
574                && source.as_str().is_some_and(is_engine)
575            {
576                *source = Value::String(proxy.clone());
577                changed += 1;
578            }
579        }
580    }
581    changed
582}
583
584fn check_host_path(
585    source: &Path,
586    allowed: &dyn Fn(&Path) -> Access,
587    violations: &mut Vec<Violation>,
588) {
589    if ENGINE_SOCKETS
590        .iter()
591        .any(|socket| source == Path::new(socket))
592    {
593        violations.push(Violation::new(
594            "engine_socket",
595            format!("{} is the Docker engine itself", source.display()),
596        ));
597        return;
598    }
599    match allowed(source) {
600        Access::Allowed => {}
601        Access::Protected => violations.push(Violation::new(
602            PROTECTED_PATH,
603            format!("{} is a protected host path", source.display()),
604        )),
605        Access::Outside => violations.push(Violation::new(
606            "host_path",
607            format!("{} is outside the job's own directories", source.display()),
608        )),
609    }
610}
611
612/// `path` with its longest existing prefix resolved through links.
613#[must_use]
614pub fn resolve(path: &Path) -> Option<PathBuf> {
615    let mut existing = path.to_path_buf();
616    let mut rest = Vec::new();
617    loop {
618        match fs::canonicalize(&existing) {
619            Ok(mut resolved) => {
620                for component in rest.iter().rev() {
621                    resolved.push(component);
622                }
623                return Some(resolved);
624            }
625            Err(_) => {
626                rest.push(existing.file_name()?.to_owned());
627                existing = existing.parent()?.to_path_buf();
628            }
629        }
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use serde_json::json;
637
638    fn rules(violations: &[Violation]) -> Vec<&str> {
639        violations
640            .iter()
641            .map(|violation| violation.rule.as_str())
642            .collect()
643    }
644
645    #[test]
646    fn the_version_prefix_is_dropped_and_targets_are_found() {
647        assert_eq!(
648            unversioned("/v1.47/containers/abc/json"),
649            "/containers/abc/json"
650        );
651        assert_eq!(unversioned("/containers/abc/json"), "/containers/abc/json");
652        assert_eq!(unversioned("/version"), "/version");
653        assert_eq!(target("/containers/abc/exec"), Target::Container("abc"));
654        assert_eq!(target("/containers/create"), Target::None);
655        assert_eq!(target("/containers/json"), Target::None);
656        assert_eq!(target("/exec/e1/start"), Target::Exec("e1"));
657        assert_eq!(target("/images/json"), Target::None);
658    }
659
660    #[test]
661    fn host_wide_endpoints_are_violations_and_ordinary_ones_are_not() {
662        assert_eq!(
663            rules(&check_endpoint("POST", "/swarm/init", "")),
664            ["host_wide_api"]
665        );
666        assert_eq!(
667            rules(&check_endpoint("POST", "/plugins/pull", "")),
668            ["host_wide_api"]
669        );
670        assert_eq!(
671            rules(&check_endpoint("POST", "/containers/prune", "")),
672            ["host_wide_prune"]
673        );
674        assert_eq!(
675            rules(&check_endpoint("POST", "/build", "t=x&networkmode=host")),
676            ["host_network"]
677        );
678        assert!(check_endpoint("POST", "/build", "t=x").is_empty());
679        assert_eq!(
680            rules(&check_endpoint("POST", "/images/prune", "")),
681            ["host_wide_prune"]
682        );
683        assert_eq!(
684            rules(&check_endpoint("POST", "/libpod/containers/create", "")),
685            ["unchecked_api"]
686        );
687        assert!(check_endpoint("GET", "/containers/json", "").is_empty());
688        assert!(check_endpoint("POST", "/images/create", "fromImage=x").is_empty());
689    }
690
691    #[test]
692    fn a_container_that_reaches_for_the_host_breaks_each_rule_it_touches() {
693        let body = json!({
694            "HostConfig": {
695                "Privileged": true,
696                "NetworkMode": "host",
697                "PidMode": "host",
698                "CapAdd": ["SYS_ADMIN"],
699                "Devices": [{"PathOnHost": "/dev/sda"}],
700                "SecurityOpt": ["seccomp=unconfined"],
701                "Binds": ["/etc:/host-etc:ro", "/var/run/docker.sock:/var/run/docker.sock", "named:/data"],
702                "Mounts": [{"Type": "bind", "Source": "/root", "Target": "/r"}]
703            }
704        });
705        let found = check_container_create(&body, &|_| Access::Outside);
706        assert_eq!(
707            rules(&found),
708            [
709                "privileged",
710                "host_network",
711                "host_pid",
712                "added_capability",
713                "host_device",
714                "weakened_security",
715                "host_path",
716                "engine_socket",
717                "host_path"
718            ]
719        );
720    }
721
722    #[test]
723    fn an_ordinary_job_container_breaks_nothing() {
724        let body = json!({
725            "Image": "node:22",
726            "HostConfig": {
727                "NetworkMode": "bridge",
728                "Binds": ["/attempt/_work:/__w", "/attempt/externals:/__e:ro", "cache:/cache"],
729                "Mounts": [{"Type": "tmpfs", "Target": "/tmp"}]
730            }
731        });
732        let found = check_container_create(&body, &|path| {
733            if path.starts_with("/attempt") {
734                Access::Allowed
735            } else {
736                Access::Outside
737            }
738        });
739        assert!(found.is_empty(), "{found:?}");
740    }
741
742    #[test]
743    fn containers_a_new_one_joins_or_borrows_from_are_named() {
744        let body = json!({"HostConfig": {
745            "NetworkMode": "container:db",
746            "PidMode": "host",
747            "VolumesFrom": ["data:ro", "cache"]
748        }});
749        assert_eq!(referenced_containers(&body), ["db", "data", "cache"]);
750    }
751
752    #[test]
753    fn volumes_and_networks_that_bind_the_host_are_violations() {
754        let bound = json!({"Driver": "local", "DriverOpts": {"type": "none", "o": "bind", "device": "/srv/secret"}});
755        assert_eq!(
756            rules(&check_volume_create(&bound, &|_| Access::Outside)),
757            ["host_path"]
758        );
759        assert!(check_volume_create(&json!({"Driver": "local"}), &|_| Access::Outside).is_empty());
760        let disk =
761            json!({"Driver": "local", "DriverOpts": {"type": "ext4", "device": "/dev/sda1"}});
762        assert_eq!(
763            rules(&check_volume_create(&disk, &|_| Access::Outside)),
764            ["host_path"],
765            "a host block device is mounted without any bind option"
766        );
767        assert_eq!(
768            rules(&check_container_create(
769                &json!({"HostConfig": {"MaskedPaths": [], "ReadonlyPaths": null}}),
770                &|_| Access::Outside
771            )),
772            ["weakened_security"]
773        );
774        assert_eq!(
775            rules(&check_network_create(&json!({"Driver": "macvlan"}))),
776            ["host_network"]
777        );
778        assert!(check_network_create(&json!({"Driver": "bridge"})).is_empty());
779        assert_eq!(
780            rules(&check_exec_create(&json!({"Privileged": true}))),
781            ["privileged"]
782        );
783        assert!(check_exec_create(&json!({"Cmd": ["sh"]})).is_empty());
784    }
785
786    #[cfg(unix)]
787    #[test]
788    fn a_link_inside_an_allowed_directory_does_not_carry_a_mount_outside_it() {
789        let root = tempfile::tempdir().unwrap();
790        let outside = tempfile::tempdir().unwrap();
791        let attempt = root.path().join("attempt");
792        fs::create_dir_all(attempt.join("_work")).unwrap();
793        std::os::unix::fs::symlink(outside.path(), attempt.join("escape")).unwrap();
794        let context = Context {
795            mode: Mode::Enforce,
796            allowed: vec![fs::canonicalize(&attempt).unwrap()],
797            protected: vec![],
798            log: root.path().join("log"),
799        };
800        let inside = |path: &Path| context.access(path) == Access::Allowed;
801        assert!(inside(&attempt.join("_work")));
802        assert!(inside(&attempt.join("_work/not/yet/created")));
803        assert!(!inside(&attempt.join("escape")));
804        assert!(!inside(&attempt.join("escape/inner")));
805        assert!(!inside(&attempt.join("_work/../../etc")));
806        assert!(!inside(Path::new("relative")));
807    }
808
809    #[cfg(unix)]
810    #[test]
811    fn a_protected_path_is_refused_even_inside_an_allowed_directory() {
812        let root = tempfile::tempdir().unwrap();
813        let shared = root.path().join("shared");
814        fs::create_dir_all(shared.join("keys")).unwrap();
815        fs::create_dir_all(shared.join("data")).unwrap();
816        let context = Context {
817            mode: Mode::Enforce,
818            allowed: vec![fs::canonicalize(&shared).unwrap()],
819            protected: vec![fs::canonicalize(shared.join("keys")).unwrap()],
820            log: root.path().join("log"),
821        };
822        assert_eq!(context.access(&shared.join("data")), Access::Allowed);
823        assert_eq!(context.access(&shared.join("keys/id")), Access::Protected);
824        assert_eq!(
825            context.access(&shared),
826            Access::Protected,
827            "a parent exposes it too"
828        );
829        assert_eq!(context.access(Path::new("/etc")), Access::Outside);
830        let body =
831            json!({"HostConfig": {"Binds": [format!("{}:/k", shared.join("keys").display())]}});
832        assert_eq!(
833            rules(&check_container_create(&body, &|path| context.access(path))),
834            ["protected_path"]
835        );
836    }
837
838    #[test]
839    fn a_protected_path_is_refused_in_every_mode_and_the_rest_only_when_enforced() {
840        let protected = Violation::new(PROTECTED_PATH, "/srv/keys");
841        let privileged = Violation::new("privileged", "a privileged container");
842        for (mode, recorded, refused) in [
843            (Mode::Off, false, false),
844            (Mode::Audit, true, false),
845            (Mode::Enforce, true, true),
846        ] {
847            let context = Context {
848                mode,
849                allowed: vec![],
850                protected: vec![],
851                log: PathBuf::from("log"),
852            };
853            assert!(context.records(&protected) && context.refuses(&protected));
854            assert_eq!(context.records(&privileged), recorded, "{mode:?}");
855            assert_eq!(context.refuses(&privileged), refused, "{mode:?}");
856        }
857    }
858
859    #[test]
860    fn the_engine_socket_a_job_container_is_given_becomes_the_proxy() {
861        let mut body = json!({"HostConfig": {
862            "Binds": ["/var/run/docker.sock:/var/run/docker.sock", "/attempt/_work:/__w"],
863            "Mounts": [{"Type": "bind", "Source": "/run/docker.sock", "Target": "/d.sock"}]
864        }});
865        let proxy = Path::new("/attempt/.docker-proxy/s");
866        assert_eq!(redirect_engine_socket(&mut body, proxy), 2);
867        assert_eq!(
868            body.pointer("/HostConfig/Binds/0").unwrap(),
869            "/attempt/.docker-proxy/s:/var/run/docker.sock"
870        );
871        assert_eq!(
872            body.pointer("/HostConfig/Binds/1").unwrap(),
873            "/attempt/_work:/__w"
874        );
875        assert_eq!(
876            body.pointer("/HostConfig/Mounts/0/Source").unwrap(),
877            "/attempt/.docker-proxy/s"
878        );
879        let allowed = |path: &Path| {
880            if path == proxy || path.starts_with("/attempt") {
881                Access::Allowed
882            } else {
883                Access::Outside
884            }
885        };
886        assert!(check_container_create(&body, &allowed).is_empty());
887    }
888
889    #[test]
890    fn records_round_trip_through_a_rotating_log() {
891        let directory = tempfile::tempdir().unwrap();
892        let log = directory.path().join(LOG_DIR).join(LOG_FILE);
893        let entry = Record {
894            at: "2026-11-10T00:00:00Z".into(),
895            attempt: "a".into(),
896            repository: "o/r".into(),
897            rule: "privileged".into(),
898            detail: "a privileged container".into(),
899            refused: false,
900        };
901        record(&log, std::slice::from_ref(&entry)).unwrap();
902        record(&log, std::slice::from_ref(&entry)).unwrap();
903        assert_eq!(read_records(&log), [entry.clone(), entry]);
904    }
905}