Skip to main content

gha_container_proof/
plan.rs

1//! Classifiers for concrete job-container and Docker-action requests.
2
3use 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/// Concrete plan-job inputs.
14#[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    /// Optional source location, used for `check-workflow` to point at the
27    /// originating workflow path.
28    pub location: Option<String>,
29}
30
31/// Concrete plan-action inputs.
32#[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
47/// Classify a job container.
48pub 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
106/// Classify a Docker action invocation.
107pub 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    // Try to load the manifest from a local path; fall back to the inline inputs.
116    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    // Merge manifest fields with CLI-supplied overrides; CLI wins where set.
179    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    // Quick docker:// fast path uses the raw ref when image was not set. The
220    // `--action-path` argument may be a directory or the manifest file itself
221    // (e.g. when workflow.rs has already resolved to action.yml); resolve to
222    // the containing directory so relative paths like `Dockerfile` work.
223    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    // --using validation.
239    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    // Image classification.
278    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            // Pin check: unless pinned, classification stays compatible.
345            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    // Either a bare container path (`/data`), a HOST:DEST[:MODE] mount, or a
608    // Windows HOST:DEST where the drive letter introduces a third colon. The
609    // disambiguating signal is: at least one segment is an absolute container
610    // path (starts with `/`). looks_like_windows_host_path elsewhere emits the
611    // appropriate warning; the parse just decides "well-formed enough".
612    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
644/// Allow `image_pin_check`-style helpers to carry a location through the
645/// builder chain without explicit `if let` ladders.
646trait 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        // node:22-bookworm has an explicit tag, ubuntu-22.04 is a Linux runner,
687        // --cpus 2 is a recognized option — nothing should warn or fail.
688        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}