Skip to main content

gha_container_proof/
options.rs

1//! Parser for `container.options` (the raw Docker option string that ci-forge
2//! passes to `docker run`). The parser tokenizes via [`shell_words`], walks the
3//! tokens looking for known flags, and emits structured findings that the
4//! engine layers into the subject's checks.
5
6use crate::model::{Check, NetworkModel, Subject};
7use serde::{Deserialize, Serialize};
8
9/// Result of parsing one options string.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct OptionsPlan {
12    pub raw: String,
13    pub tokens: Vec<String>,
14    pub classified: Vec<ClassifiedOption>,
15    pub unsupported: Vec<String>,
16    pub risky: Vec<String>,
17    pub unknown: Vec<String>,
18    pub network_model: NetworkModel,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ClassifiedOption {
23    pub flag: String,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub value: Option<String>,
26    pub kind: OptionKind,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
30#[serde(rename_all = "kebab-case")]
31pub enum OptionKind {
32    User,
33    Workdir,
34    Entrypoint,
35    Env,
36    Volume,
37    Port,
38    Cpus,
39    Memory,
40    Network,
41    Privileged,
42    HostNamespace,
43    SecurityOpt,
44    CapAdd,
45    Unknown,
46}
47
48pub fn parse_options(raw: &str) -> Result<OptionsPlan, String> {
49    let trimmed = raw.trim();
50    let tokens = if trimmed.is_empty() {
51        Vec::new()
52    } else {
53        shell_words::split(raw).map_err(|err| format!("could not parse Docker options: {err}"))?
54    };
55
56    let mut plan = OptionsPlan {
57        raw: raw.to_owned(),
58        tokens: tokens.clone(),
59        classified: Vec::new(),
60        unsupported: Vec::new(),
61        risky: Vec::new(),
62        unknown: Vec::new(),
63        network_model: NetworkModel::DockerDefault,
64    };
65
66    let mut index = 0usize;
67    while index < tokens.len() {
68        let token = tokens[index].clone();
69        let (flag, inline) = split_flag(&token);
70        match flag.as_str() {
71            "--network" | "--net" => {
72                consume_value(&tokens, &mut index, inline.as_deref());
73                plan.unsupported.push(flag.clone());
74                plan.classified.push(ClassifiedOption {
75                    flag: flag.clone(),
76                    value: inline,
77                    kind: OptionKind::Network,
78                });
79                plan.network_model = NetworkModel::UnsupportedCustom;
80            }
81            "--privileged" => {
82                plan.risky.push(flag.clone());
83                plan.classified.push(ClassifiedOption {
84                    flag,
85                    value: None,
86                    kind: OptionKind::Privileged,
87                });
88            }
89            "--pid" | "--ipc" | "--uts" if inline.as_deref() == Some("host") => {
90                plan.risky.push(format!("{flag}=host"));
91                plan.classified.push(ClassifiedOption {
92                    flag,
93                    value: Some("host".to_owned()),
94                    kind: OptionKind::HostNamespace,
95                });
96            }
97            "--pid=host" | "--ipc=host" | "--uts=host" => {
98                plan.risky.push(flag.clone());
99                plan.classified.push(ClassifiedOption {
100                    flag,
101                    value: Some("host".to_owned()),
102                    kind: OptionKind::HostNamespace,
103                });
104            }
105            "--security-opt" => {
106                let value = consume_value(&tokens, &mut index, inline.as_deref());
107                plan.risky.push(format!(
108                    "--security-opt {}",
109                    value.clone().unwrap_or_default()
110                ));
111                plan.classified.push(ClassifiedOption {
112                    flag,
113                    value,
114                    kind: OptionKind::SecurityOpt,
115                });
116            }
117            "--cap-add" => {
118                let value = consume_value(&tokens, &mut index, inline.as_deref());
119                plan.risky
120                    .push(format!("--cap-add {}", value.clone().unwrap_or_default()));
121                plan.classified.push(ClassifiedOption {
122                    flag,
123                    value,
124                    kind: OptionKind::CapAdd,
125                });
126            }
127            "--user" | "-u" => {
128                let value = consume_value(&tokens, &mut index, inline.as_deref());
129                plan.classified.push(ClassifiedOption {
130                    flag,
131                    value,
132                    kind: OptionKind::User,
133                });
134            }
135            "--workdir" | "-w" => {
136                let value = consume_value(&tokens, &mut index, inline.as_deref());
137                plan.classified.push(ClassifiedOption {
138                    flag,
139                    value,
140                    kind: OptionKind::Workdir,
141                });
142            }
143            "--entrypoint" => {
144                let value = consume_value(&tokens, &mut index, inline.as_deref());
145                plan.classified.push(ClassifiedOption {
146                    flag,
147                    value,
148                    kind: OptionKind::Entrypoint,
149                });
150            }
151            "--env" | "-e" => {
152                let value = consume_value(&tokens, &mut index, inline.as_deref());
153                plan.classified.push(ClassifiedOption {
154                    flag,
155                    value,
156                    kind: OptionKind::Env,
157                });
158            }
159            "--volume" | "-v" => {
160                let value = consume_value(&tokens, &mut index, inline.as_deref());
161                plan.classified.push(ClassifiedOption {
162                    flag,
163                    value,
164                    kind: OptionKind::Volume,
165                });
166            }
167            "--publish" | "-p" => {
168                let value = consume_value(&tokens, &mut index, inline.as_deref());
169                plan.classified.push(ClassifiedOption {
170                    flag,
171                    value,
172                    kind: OptionKind::Port,
173                });
174            }
175            "--cpus" => {
176                let value = consume_value(&tokens, &mut index, inline.as_deref());
177                plan.classified.push(ClassifiedOption {
178                    flag,
179                    value,
180                    kind: OptionKind::Cpus,
181                });
182            }
183            "--memory" | "-m" => {
184                let value = consume_value(&tokens, &mut index, inline.as_deref());
185                plan.classified.push(ClassifiedOption {
186                    flag,
187                    value,
188                    kind: OptionKind::Memory,
189                });
190            }
191            other if other.starts_with("--") || (other.starts_with('-') && other.len() == 2) => {
192                plan.unknown.push(other.to_owned());
193                plan.classified.push(ClassifiedOption {
194                    flag: other.to_owned(),
195                    value: inline,
196                    kind: OptionKind::Unknown,
197                });
198            }
199            _ => {
200                // Bare positional tokens (image names, sub-args of the previous
201                // unknown flag, etc.) are not classified.
202            }
203        }
204        index += 1;
205    }
206
207    Ok(plan)
208}
209
210fn split_flag(token: &str) -> (String, Option<String>) {
211    if let Some((flag, value)) = token.split_once('=') {
212        (flag.to_owned(), Some(value.to_owned()))
213    } else {
214        (token.to_owned(), None)
215    }
216}
217
218fn consume_value(tokens: &[String], index: &mut usize, inline: Option<&str>) -> Option<String> {
219    if let Some(value) = inline {
220        return Some(value.to_owned());
221    }
222    if *index + 1 < tokens.len() {
223        *index += 1;
224        return Some(tokens[*index].clone());
225    }
226    None
227}
228
229/// Apply the parsed options plan to a subject by appending the appropriate
230/// checks and updating the subject's `network_model` and classification.
231pub fn apply_options_to_subject(plan: &OptionsPlan, subject: &mut Subject) {
232    if plan.tokens.is_empty() {
233        subject.push(Check::pass(
234            "container.options.parse",
235            "no Docker options to parse",
236        ));
237        return;
238    }
239    subject.push(Check::pass(
240        "container.options.parse",
241        format!("parsed {} option token(s)", plan.tokens.len()),
242    ));
243
244    for option in &plan.classified {
245        match option.kind {
246            OptionKind::Network => {
247                let value = option.value.clone().unwrap_or_default();
248                subject.push(Check::fail(
249                    "container.options.network",
250                    format!(
251                        "{} {} is not supported under ci-forge-managed networking",
252                        option.flag, value
253                    ),
254                ));
255            }
256            OptionKind::Privileged => {
257                subject.push(Check::warn(
258                    "container.options.privileged",
259                    "--privileged broadens container isolation",
260                ));
261            }
262            OptionKind::HostNamespace => {
263                subject.push(Check::warn(
264                    "container.options.host_namespace",
265                    format!(
266                        "{}={} broadens container isolation",
267                        option.flag,
268                        option.value.as_deref().unwrap_or("host")
269                    ),
270                ));
271            }
272            OptionKind::SecurityOpt => {
273                subject.push(Check::warn(
274                    "container.options.security_opt",
275                    format!(
276                        "--security-opt {} broadens container isolation",
277                        option.value.as_deref().unwrap_or("")
278                    ),
279                ));
280            }
281            OptionKind::CapAdd => {
282                subject.push(Check::warn(
283                    "container.options.cap_add",
284                    format!(
285                        "--cap-add {} broadens container capabilities",
286                        option.value.as_deref().unwrap_or("")
287                    ),
288                ));
289            }
290            OptionKind::Volume => {
291                let value = option.value.clone().unwrap_or_default();
292                if value.contains("/var/run/docker.sock") {
293                    subject.push(Check::warn(
294                        "container.volume.docker_socket",
295                        format!(
296                            "{} {} mounts the Docker socket; this grants host-level control",
297                            option.flag, value
298                        ),
299                    ));
300                } else if looks_like_windows_host_path(&value) {
301                    subject.push(Check::warn(
302                        "container.volume.windows_host_path",
303                        format!(
304                            "{} {} mounts a Windows host path into a Linux container",
305                            option.flag, value
306                        ),
307                    ));
308                } else {
309                    subject.push(Check::pass(
310                        "container.options.classified",
311                        format!("{} {} classified", option.flag, value),
312                    ));
313                }
314            }
315            OptionKind::Unknown => {
316                subject.push(Check::warn(
317                    "container.options.unknown",
318                    format!("unknown Docker flag {}", option.flag),
319                ));
320            }
321            _ => {
322                subject.push(Check::pass(
323                    "container.options.classified",
324                    format!(
325                        "{} {} classified as {:?}",
326                        option.flag,
327                        option.value.as_deref().unwrap_or("(none)"),
328                        option.kind
329                    ),
330                ));
331            }
332        }
333    }
334
335    if plan.unsupported.is_empty() {
336        subject.push(Check::pass(
337            "container.options.supported",
338            "options contain no unsupported Docker flags",
339        ));
340    }
341
342    // Only override subject.network_model when the options parser found an
343    // explicit --network / --net flag. Otherwise leave whatever the subject's
344    // surface (job-container, docker-action) already declared.
345    if plan.network_model == NetworkModel::UnsupportedCustom {
346        subject.network_model = NetworkModel::UnsupportedCustom;
347    }
348}
349
350pub fn looks_like_windows_host_path(value: &str) -> bool {
351    // A Docker volume that mounts a Windows host path takes the shape
352    // `C:\foo:/dest` or `C:/foo:/dest`. Splitting on `:` puts the drive letter
353    // in the first segment all by itself. Otherwise look for embedded
354    // backslashes anywhere in the value.
355    let first = value.split(':').next().unwrap_or("");
356    if first.len() == 1
357        && first
358            .chars()
359            .next()
360            .is_some_and(|c| c.is_ascii_alphabetic())
361    {
362        return true;
363    }
364    value.contains('\\')
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::model::SubjectKind;
371
372    #[test]
373    fn empty_options_parse_clean() {
374        let plan = parse_options("").unwrap();
375        assert!(plan.tokens.is_empty());
376        assert_eq!(plan.network_model, NetworkModel::DockerDefault);
377    }
378
379    #[test]
380    fn network_flag_is_unsupported() {
381        let plan = parse_options("--network host").unwrap();
382        assert_eq!(plan.unsupported, vec!["--network"]);
383        assert_eq!(plan.network_model, NetworkModel::UnsupportedCustom);
384    }
385
386    #[test]
387    fn net_flag_is_unsupported() {
388        let plan = parse_options("--net=bridge").unwrap();
389        assert_eq!(plan.unsupported, vec!["--net"]);
390    }
391
392    #[test]
393    fn privileged_is_risky() {
394        let plan = parse_options("--privileged").unwrap();
395        assert_eq!(plan.risky, vec!["--privileged"]);
396    }
397
398    #[test]
399    fn pid_host_is_risky() {
400        let plan = parse_options("--pid=host").unwrap();
401        assert!(plan.risky.iter().any(|item| item.contains("host")));
402    }
403
404    #[test]
405    fn cpus_and_memory_are_classified() {
406        let plan = parse_options("--cpus 2 --memory 4g").unwrap();
407        assert!(
408            plan.classified
409                .iter()
410                .any(|c| c.kind == OptionKind::Cpus && c.value.as_deref() == Some("2"))
411        );
412        assert!(
413            plan.classified
414                .iter()
415                .any(|c| c.kind == OptionKind::Memory && c.value.as_deref() == Some("4g"))
416        );
417    }
418
419    #[test]
420    fn unknown_flag_is_flagged() {
421        let plan = parse_options("--frobulate yes").unwrap();
422        assert_eq!(plan.unknown, vec!["--frobulate"]);
423    }
424
425    #[test]
426    fn docker_socket_mount_warns_subject() {
427        let plan = parse_options("-v /var/run/docker.sock:/var/run/docker.sock").unwrap();
428        let mut subject = Subject::new(SubjectKind::JobContainer);
429        apply_options_to_subject(&plan, &mut subject);
430        assert!(
431            subject
432                .checks
433                .iter()
434                .any(|check| check.id == "container.volume.docker_socket")
435        );
436    }
437
438    #[test]
439    fn windows_host_path_warns_subject() {
440        let plan = parse_options("-v C:\\work:/work").unwrap();
441        let mut subject = Subject::new(SubjectKind::JobContainer);
442        apply_options_to_subject(&plan, &mut subject);
443        assert!(
444            subject
445                .checks
446                .iter()
447                .any(|check| check.id == "container.volume.windows_host_path")
448        );
449    }
450
451    #[test]
452    fn looks_like_windows_host_path_basics() {
453        assert!(looks_like_windows_host_path("C:\\work:/work"));
454        assert!(looks_like_windows_host_path("D:/data:/data"));
455        assert!(looks_like_windows_host_path("Z:\\repo"));
456        assert!(!looks_like_windows_host_path("/host/cache:/cache"));
457        assert!(!looks_like_windows_host_path("named-volume:/data"));
458    }
459}