1use crate::model::{Check, NetworkModel, Subject};
7use serde::{Deserialize, Serialize};
8
9#[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 }
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
229pub 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 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 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}