1use camino::{Utf8Path, Utf8PathBuf};
4
5use crate::action::{ActionManifest, DockerImage, classify_image};
6use crate::model::{
7 Check, Compatibility, NetworkModel, RunnerOs, Subject, SubjectKind, is_sensitive_key,
8};
9use crate::options::{
10 OptionsPlan, apply_options_to_subject, looks_like_windows_host_path, parse_options,
11};
12
13#[derive(Debug, Clone)]
15pub struct JobPlanInput {
16 pub job_id: String,
17 pub runner_os: RunnerOs,
18 pub runs_on: Vec<String>,
19 pub container_image: Option<String>,
20 pub env: Vec<(String, String)>,
21 pub ports: Vec<String>,
22 pub volumes: Vec<String>,
23 pub options: String,
24 pub credentials_username_present: bool,
25 pub credentials_password_present: bool,
26 pub location: Option<String>,
29}
30
31#[derive(Debug, Clone)]
33pub struct ActionPlanInput {
34 pub action_ref: String,
35 pub step_id: Option<String>,
36 pub action_path: Option<Utf8PathBuf>,
37 pub using: Option<String>,
38 pub image: Option<String>,
39 pub entrypoint: Option<String>,
40 pub pre_entrypoint: Option<String>,
41 pub post_entrypoint: Option<String>,
42 pub args: Vec<String>,
43 pub env: Vec<(String, String)>,
44 pub location: Option<String>,
45}
46
47pub fn plan_job(input: &JobPlanInput) -> Subject {
49 let mut subject = Subject::new(SubjectKind::JobContainer);
50 subject.job_id = Some(input.job_id.clone());
51 subject.image = input.container_image.clone();
52 subject.runner_os = Some(input.runner_os);
53 subject.requires_docker = true;
54 subject.network_model = NetworkModel::CiForgeManaged;
55 let location = input.location.clone();
56
57 let image = input.container_image.as_deref().unwrap_or("").trim();
58 if image.is_empty() {
59 subject.push(at(
60 &location,
61 Check::fail(
62 "container.image.declared",
63 "job declares a container with no image",
64 ),
65 ));
66 } else {
67 subject.push(at(
68 &location,
69 Check::pass(
70 "container.image.declared",
71 format!("job container image is `{image}`"),
72 ),
73 ));
74 if image.contains("${{") {
75 subject.classification = Compatibility::Simulated;
76 subject.push(at(
77 &location,
78 Check::warn(
79 "container.image.expression",
80 format!("image `{image}` contains an unrendered expression"),
81 ),
82 ));
83 } else {
84 subject.push(image_pin_check(image).map_location(location.clone()));
85 }
86 }
87
88 push_runner_os_checks(&mut subject, input, &location);
89 push_runs_on_checks(&mut subject, input, &location);
90 push_credentials_checks(&mut subject, input, &location);
91 push_env_checks(&mut subject, &input.env, &location);
92 push_port_checks(&mut subject, &input.ports, &location);
93 push_volume_checks(&mut subject, &input.volumes, &location);
94
95 match parse_options(&input.options) {
96 Ok(plan) => apply_options_plan(&mut subject, &plan, &location),
97 Err(message) => subject.push(at(
98 &location,
99 Check::fail("container.options.parse", message),
100 )),
101 }
102
103 subject
104}
105
106pub fn plan_action(input: &ActionPlanInput) -> Subject {
108 let mut subject = Subject::new(SubjectKind::DockerAction);
109 subject.action_ref = Some(input.action_ref.clone());
110 subject.step_id = input.step_id.clone();
111 subject.network_model = NetworkModel::CiForgeManaged;
112 subject.requires_docker = true;
113 let location = input.location.clone();
114
115 let manifest = input
117 .action_path
118 .as_ref()
119 .map(|path| ActionManifest::read(path));
120
121 let resolved_manifest = match manifest {
122 Some(Ok(manifest)) => {
123 subject.push(at(
124 &location,
125 Check::pass(
126 "action.manifest.read",
127 format!("read action manifest at `{}`", manifest.source_path),
128 ),
129 ));
130 Some(manifest)
131 }
132 Some(Err(err)) => {
133 subject.classification = Compatibility::Simulated;
134 subject.push(at(
135 &location,
136 Check::warn(
137 "action.manifest.unavailable",
138 format!("action manifest could not be loaded: {err:#}"),
139 ),
140 ));
141 None
142 }
143 None => {
144 if input.action_ref.starts_with("docker://") {
145 subject.push(at(
146 &location,
147 Check::pass(
148 "action.manifest.read",
149 "action reference is a docker:// URI; no manifest required",
150 ),
151 ));
152 } else if input.action_ref.starts_with("./") || input.action_ref.starts_with('/') {
153 subject.classification = Compatibility::Simulated;
154 subject.push(at(
155 &location,
156 Check::warn(
157 "action.manifest.unavailable",
158 "local action reference but no --action-path provided; manifest not loaded",
159 ),
160 ));
161 } else {
162 subject.classification = Compatibility::Simulated;
163 subject.push(at(
164 &location,
165 Check::warn(
166 "action.manifest.unavailable",
167 format!(
168 "remote action `{}` requires a mirrored manifest path for full classification",
169 input.action_ref
170 ),
171 ),
172 ));
173 }
174 None
175 }
176 };
177
178 let using = input
180 .using
181 .clone()
182 .or_else(|| resolved_manifest.as_ref().and_then(|m| m.using.clone()));
183 let image_raw = input
184 .image
185 .clone()
186 .or_else(|| resolved_manifest.as_ref().and_then(|m| m.image.clone()));
187 let entrypoint = input.entrypoint.clone().or_else(|| {
188 resolved_manifest
189 .as_ref()
190 .and_then(|m| m.entrypoint.clone())
191 });
192 let pre_entrypoint = input.pre_entrypoint.clone().or_else(|| {
193 resolved_manifest
194 .as_ref()
195 .and_then(|m| m.pre_entrypoint.clone())
196 });
197 let post_entrypoint = input.post_entrypoint.clone().or_else(|| {
198 resolved_manifest
199 .as_ref()
200 .and_then(|m| m.post_entrypoint.clone())
201 });
202 let args = if input.args.is_empty() {
203 resolved_manifest
204 .as_ref()
205 .map(|m| m.args.clone())
206 .unwrap_or_default()
207 } else {
208 input.args.clone()
209 };
210 let env = if input.env.is_empty() {
211 resolved_manifest
212 .as_ref()
213 .map(|m| m.env.clone())
214 .unwrap_or_default()
215 } else {
216 input.env.clone()
217 };
218
219 let action_dir = input.action_path.as_deref().map(|p| {
224 if p.is_file() {
225 p.parent().unwrap_or(p)
226 } else {
227 p
228 }
229 });
230 let inferred_image = image_raw.clone().or_else(|| {
231 if input.action_ref.starts_with("docker://") {
232 Some(input.action_ref.clone())
233 } else {
234 None
235 }
236 });
237
238 match using.as_deref() {
240 Some("docker") | Some("Docker") => {
241 subject.push(at(
242 &location,
243 Check::pass("action.using.docker", "action uses `runs.using: docker`"),
244 ));
245 }
246 Some(other) => {
247 subject.push(at(
248 &location,
249 Check::fail(
250 "action.using.unsupported",
251 format!(
252 "action uses `runs.using: {other}`; gha-container-proof only classifies Docker actions"
253 ),
254 ),
255 ));
256 }
257 None if input.action_ref.starts_with("docker://") => {
258 subject.push(at(
259 &location,
260 Check::pass(
261 "action.using.docker",
262 "docker:// step has implicit `runs.using: docker`",
263 ),
264 ));
265 }
266 None => {
267 subject.push(at(
268 &location,
269 Check::warn(
270 "action.using.unsupported",
271 "no `runs.using` declared and no docker:// shortcut",
272 ),
273 ));
274 }
275 }
276
277 classify_action_image(
279 &mut subject,
280 inferred_image.as_deref(),
281 action_dir,
282 &location,
283 );
284
285 if let Some(value) = &entrypoint {
286 subject.push(at(
287 &location,
288 Check::pass(
289 "action.entrypoint.declared",
290 format!("entrypoint: `{value}`"),
291 ),
292 ));
293 }
294 if let Some(value) = &pre_entrypoint {
295 subject.push(at(
296 &location,
297 Check::pass(
298 "action.pre_entrypoint.declared",
299 format!("pre-entrypoint: `{value}` (requires pre-job hook)"),
300 ),
301 ));
302 }
303 if let Some(value) = &post_entrypoint {
304 subject.push(at(
305 &location,
306 Check::pass(
307 "action.post_entrypoint.declared",
308 format!("post-entrypoint: `{value}` (requires post-job hook)"),
309 ),
310 ));
311 }
312 if !args.is_empty() {
313 subject.push(at(
314 &location,
315 Check::pass(
316 "action.args.preserved",
317 format!("preserved {} arg(s)", args.len()),
318 ),
319 ));
320 }
321
322 push_env_checks(&mut subject, &env, &location);
323
324 subject
325}
326
327fn classify_action_image(
328 subject: &mut Subject,
329 image_raw: Option<&str>,
330 action_dir: Option<&Utf8Path>,
331 location: &Option<String>,
332) {
333 let classification = classify_image(image_raw, action_dir);
334 match classification {
335 DockerImage::DockerUri(image) => {
336 subject.image = Some(image.clone());
337 subject.push(at(
338 location,
339 Check::pass(
340 "action.image.docker_uri",
341 format!("image is `docker://{image}`"),
342 ),
343 ));
344 if !image.contains("@sha256:") {
346 if let Some((_, tag)) = image.rsplit_once(':') {
347 if tag.eq_ignore_ascii_case("latest") {
348 subject.push(at(
349 location,
350 Check::warn(
351 "container.image.pin",
352 format!("image `{image}` uses `latest`; pin by tag or digest"),
353 ),
354 ));
355 }
356 } else {
357 subject.push(at(
358 location,
359 Check::warn(
360 "container.image.pin",
361 format!("image `{image}` has no tag; defaulting to `latest`"),
362 ),
363 ));
364 }
365 }
366 }
367 DockerImage::Dockerfile(path) => {
368 subject.dockerfile = Some(path.to_string());
369 subject.requires_build = true;
370 if path.exists() {
371 subject.push(at(
372 location,
373 Check::pass(
374 "action.image.dockerfile",
375 format!("Dockerfile available at `{path}` (build required)"),
376 ),
377 ));
378 } else {
379 subject.push(at(
380 location,
381 Check::fail(
382 "action.image.dockerfile_missing",
383 format!("Dockerfile `{path}` does not exist"),
384 ),
385 ));
386 }
387 }
388 DockerImage::Missing => {
389 subject.push(at(
390 location,
391 Check::fail("action.image.missing", "`runs.image` is missing or empty"),
392 ));
393 }
394 }
395}
396
397fn push_runner_os_checks(subject: &mut Subject, input: &JobPlanInput, location: &Option<String>) {
398 match input.runner_os {
399 RunnerOs::Linux => subject.push(at(
400 location,
401 Check::pass("container.runner_os.linux", "configured runner OS is Linux"),
402 )),
403 other => subject.push(at(
404 location,
405 Check::fail(
406 "container.runner_os.unsupported",
407 format!(
408 "job containers require a Linux runner; configured runner OS is {}",
409 other.gha_name()
410 ),
411 ),
412 )),
413 }
414}
415
416fn push_runs_on_checks(subject: &mut Subject, input: &JobPlanInput, location: &Option<String>) {
417 if input.runs_on.is_empty() {
418 return;
419 }
420 let lowered = input
421 .runs_on
422 .iter()
423 .map(|label| label.to_ascii_lowercase())
424 .collect::<Vec<_>>();
425 if lowered.iter().any(|label| {
426 label.contains("ubuntu") || label.contains("linux") || label.contains("self-hosted")
427 }) {
428 subject.push(at(
429 location,
430 Check::pass(
431 "container.runs_on.linux",
432 "runs-on appears compatible with Linux containers",
433 ),
434 ));
435 } else if lowered
436 .iter()
437 .any(|label| label.contains("windows") || label.contains("macos"))
438 {
439 subject.push(at(
440 location,
441 Check::fail(
442 "container.runs_on.linux",
443 "runs-on targets a non-Linux runner while declaring a job container",
444 ),
445 ));
446 } else if input.runs_on.iter().any(|label| label.contains("${{")) {
447 subject.push(at(
448 location,
449 Check::warn(
450 "container.runs_on.linux",
451 "runs-on contains expressions; Linux compatibility cannot be proven statically",
452 ),
453 ));
454 } else {
455 subject.push(at(
456 location,
457 Check::warn(
458 "container.runs_on.linux",
459 "runs-on does not clearly identify a Linux runner",
460 ),
461 ));
462 }
463}
464
465fn push_credentials_checks(subject: &mut Subject, input: &JobPlanInput, location: &Option<String>) {
466 let username = input.credentials_username_present;
467 let password = input.credentials_password_present;
468 match (username, password) {
469 (true, true) => {
470 subject
471 .credentials_redacted
472 .extend(["username".to_owned(), "password".to_owned()]);
473 subject.push(at(
474 location,
475 Check::pass(
476 "container.credentials.present",
477 "container.credentials.username and .password both present (values redacted)",
478 ),
479 ));
480 }
481 (true, false) | (false, true) => {
482 if username {
483 subject.credentials_redacted.push("username".to_owned());
484 }
485 if password {
486 subject.credentials_redacted.push("password".to_owned());
487 }
488 subject.push(at(
489 location,
490 Check::warn(
491 "container.credentials.partial",
492 "container.credentials declared only one of username/password",
493 ),
494 ));
495 }
496 (false, false) => {}
497 }
498}
499
500fn push_env_checks(subject: &mut Subject, env: &[(String, String)], location: &Option<String>) {
501 for (key, _value) in env {
502 if is_sensitive_key(key) {
503 subject.env_redacted.push(key.clone());
504 subject.push(at(
505 location,
506 Check::pass(
507 "container.env.redacted",
508 format!("env key `{key}` redacted before recording"),
509 ),
510 ));
511 }
512 }
513}
514
515fn push_port_checks(subject: &mut Subject, ports: &[String], location: &Option<String>) {
516 for raw in ports {
517 let trimmed = raw.trim();
518 if trimmed.is_empty() {
519 continue;
520 }
521 if validate_port_mapping(trimmed) {
522 subject.push(at(
523 location,
524 Check::pass("container.port.parse", format!("port `{trimmed}` parsed")),
525 ));
526 } else {
527 subject.push(at(
528 location,
529 Check::fail(
530 "container.port.parse",
531 format!("port `{trimmed}` is not in CONTAINER or HOST:CONTAINER form"),
532 ),
533 ));
534 }
535 }
536}
537
538fn push_volume_checks(subject: &mut Subject, volumes: &[String], location: &Option<String>) {
539 for raw in volumes {
540 let trimmed = raw.trim();
541 if trimmed.is_empty() {
542 continue;
543 }
544 if trimmed.contains("/var/run/docker.sock") {
545 subject.push(at(
546 location,
547 Check::warn(
548 "container.volume.docker_socket",
549 format!("volume `{trimmed}` mounts the Docker socket"),
550 ),
551 ));
552 } else if looks_like_windows_host_path(trimmed) {
553 subject.push(at(
554 location,
555 Check::warn(
556 "container.volume.windows_host_path",
557 format!("volume `{trimmed}` mounts a Windows host path"),
558 ),
559 ));
560 }
561 if validate_volume(trimmed) {
562 subject.push(at(
563 location,
564 Check::pass(
565 "container.volume.parse",
566 format!("volume `{trimmed}` parsed"),
567 ),
568 ));
569 } else {
570 subject.push(at(
571 location,
572 Check::fail(
573 "container.volume.parse",
574 format!("volume `{trimmed}` could not be parsed"),
575 ),
576 ));
577 }
578 }
579}
580
581fn apply_options_plan(subject: &mut Subject, plan: &OptionsPlan, location: &Option<String>) {
582 let before = subject.checks.len();
583 apply_options_to_subject(plan, subject);
584 if let Some(loc) = location {
585 for check in subject.checks.iter_mut().skip(before) {
586 if check.location.is_none() {
587 check.location = Some(loc.clone());
588 }
589 }
590 }
591}
592
593fn validate_port_mapping(raw: &str) -> bool {
594 let (body, _proto) = raw.rsplit_once('/').unwrap_or((raw, ""));
595 let parts = body.split(':').collect::<Vec<_>>();
596 match parts.as_slice() {
597 [container] => container.parse::<u16>().is_ok(),
598 [host, container] => host.parse::<u16>().is_ok() && container.parse::<u16>().is_ok(),
599 _ => false,
600 }
601}
602
603fn validate_volume(raw: &str) -> bool {
604 if raw.is_empty() {
605 return false;
606 }
607 raw.split(':').any(|segment| segment.starts_with('/'))
613}
614
615fn image_pin_check(image: &str) -> Check {
616 if image.contains("@sha256:") {
617 return Check::pass(
618 "container.image.pin",
619 format!("image `{image}` is pinned by digest"),
620 );
621 }
622 let last = image.rsplit('/').next().unwrap_or(image);
623 if let Some((_, tag)) = last.rsplit_once(':') {
624 if !tag.eq_ignore_ascii_case("latest") && !tag.trim().is_empty() {
625 return Check::pass(
626 "container.image.pin",
627 format!("image `{image}` has an explicit tag `{tag}`"),
628 );
629 }
630 }
631 Check::warn(
632 "container.image.pin",
633 format!("image `{image}` is not pinned by digest or non-latest tag"),
634 )
635}
636
637fn at(location: &Option<String>, check: Check) -> Check {
638 match location {
639 Some(loc) if check.location.is_none() => check.at(loc.clone()),
640 _ => check,
641 }
642}
643
644trait WithLocation {
647 fn map_location(self, location: Option<String>) -> Self;
648}
649
650impl WithLocation for Check {
651 fn map_location(self, location: Option<String>) -> Self {
652 match location {
653 Some(loc) => self.at(loc),
654 None => self,
655 }
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 fn job_input() -> JobPlanInput {
664 JobPlanInput {
665 job_id: "build".to_owned(),
666 runner_os: RunnerOs::Linux,
667 runs_on: vec!["ubuntu-22.04".to_owned()],
668 container_image: Some("node:22-bookworm".to_owned()),
669 env: vec![("NODE_ENV".to_owned(), "test".to_owned())],
670 ports: vec!["3000".to_owned()],
671 volumes: vec!["/host/cache:/cache".to_owned()],
672 options: "--cpus 2".to_owned(),
673 credentials_username_present: false,
674 credentials_password_present: false,
675 location: None,
676 }
677 }
678
679 #[test]
680 fn job_classifies_clean_linux_container() {
681 let subject = {
682 let mut subject = plan_job(&job_input());
683 subject.finalize();
684 subject
685 };
686 assert_eq!(subject.classification, Compatibility::Exact);
689 assert!(subject.requires_docker);
690 assert!(
691 subject
692 .checks
693 .iter()
694 .any(|c| c.id == "container.image.declared"
695 && c.message.contains("node:22-bookworm"))
696 );
697 }
698
699 #[test]
700 fn job_on_windows_runner_fails() {
701 let mut input = job_input();
702 input.runner_os = RunnerOs::Windows;
703 let mut subject = plan_job(&input);
704 subject.finalize();
705 assert_eq!(subject.classification, Compatibility::Unsupported);
706 assert!(
707 subject
708 .checks
709 .iter()
710 .any(|c| c.id == "container.runner_os.unsupported")
711 );
712 }
713
714 #[test]
715 fn job_with_network_option_fails() {
716 let mut input = job_input();
717 input.options = "--network host".to_owned();
718 let mut subject = plan_job(&input);
719 subject.finalize();
720 assert_eq!(subject.classification, Compatibility::Unsupported);
721 assert!(
722 subject
723 .checks
724 .iter()
725 .any(|c| c.id == "container.options.network")
726 );
727 assert_eq!(subject.network_model, NetworkModel::UnsupportedCustom);
728 }
729
730 #[test]
731 fn job_redacts_password_env() {
732 let mut input = job_input();
733 input
734 .env
735 .push(("DATABASE_PASSWORD".to_owned(), "secret".to_owned()));
736 let subject = plan_job(&input);
737 assert!(
738 subject
739 .env_redacted
740 .contains(&"DATABASE_PASSWORD".to_owned())
741 );
742 }
743
744 #[test]
745 fn action_with_docker_uri_classifies_exact() {
746 let mut subject = plan_action(&ActionPlanInput {
747 action_ref: "docker://alpine:3.20".to_owned(),
748 step_id: Some("step-1".to_owned()),
749 action_path: None,
750 using: None,
751 image: Some("docker://alpine:3.20".to_owned()),
752 entrypoint: None,
753 pre_entrypoint: None,
754 post_entrypoint: None,
755 args: Vec::new(),
756 env: Vec::new(),
757 location: None,
758 });
759 subject.finalize();
760 assert_eq!(subject.classification, Compatibility::Exact);
761 assert!(
762 subject
763 .checks
764 .iter()
765 .any(|c| c.id == "action.image.docker_uri")
766 );
767 }
768
769 #[test]
770 fn action_with_missing_dockerfile_fails() {
771 let mut subject = plan_action(&ActionPlanInput {
772 action_ref: "./missing-action".to_owned(),
773 step_id: None,
774 action_path: None,
775 using: Some("docker".to_owned()),
776 image: Some("Dockerfile".to_owned()),
777 entrypoint: None,
778 pre_entrypoint: None,
779 post_entrypoint: None,
780 args: Vec::new(),
781 env: Vec::new(),
782 location: None,
783 });
784 subject.finalize();
785 assert_eq!(subject.classification, Compatibility::Unsupported);
786 assert!(
787 subject
788 .checks
789 .iter()
790 .any(|c| c.id == "action.image.dockerfile_missing")
791 );
792 }
793
794 #[test]
795 fn action_unsupported_using_fails() {
796 let mut subject = plan_action(&ActionPlanInput {
797 action_ref: "./javascript-action".to_owned(),
798 step_id: None,
799 action_path: None,
800 using: Some("node20".to_owned()),
801 image: None,
802 entrypoint: None,
803 pre_entrypoint: None,
804 post_entrypoint: None,
805 args: Vec::new(),
806 env: Vec::new(),
807 location: None,
808 });
809 subject.finalize();
810 assert_eq!(subject.classification, Compatibility::Unsupported);
811 assert!(
812 subject
813 .checks
814 .iter()
815 .any(|c| c.id == "action.using.unsupported")
816 );
817 }
818}