Skip to main content

gha_container_proof/
model.rs

1//! Public receipt schema for `gha-container-proof`.
2//!
3//! The shape of every command's output: a [`ContainerProofReceipt`] with a
4//! schema version, tool identity, mode, overall compatibility rollup, summary,
5//! a list of subjects (job containers, Docker actions, Docker probes), and a
6//! flat list of checks aggregated from all subjects.
7
8use chrono::{DateTime, Utc};
9use clap::ValueEnum;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13pub type SchemaVersion = u32;
14pub const SCHEMA_VERSION: SchemaVersion = 1;
15
16#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "lowercase")]
18pub enum OutputFormat {
19    Text,
20    Json,
21    Markdown,
22}
23
24#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize, PartialEq, Eq, Hash)]
25#[serde(rename_all = "lowercase")]
26pub enum RunnerOs {
27    Linux,
28    Windows,
29    Macos,
30}
31
32impl RunnerOs {
33    pub fn gha_name(self) -> &'static str {
34        match self {
35            Self::Linux => "Linux",
36            Self::Windows => "Windows",
37            Self::Macos => "macOS",
38        }
39    }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct ToolInfo {
44    pub name: String,
45    pub version: String,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
49#[serde(rename_all = "lowercase")]
50pub enum CheckStatus {
51    Pass,
52    Warn,
53    Fail,
54    Skip,
55}
56
57impl CheckStatus {
58    pub fn symbol(self) -> &'static str {
59        match self {
60            Self::Pass => "✓",
61            Self::Warn => "!",
62            Self::Fail => "✗",
63            Self::Skip => "-",
64        }
65    }
66
67    pub fn word(self) -> &'static str {
68        match self {
69            Self::Pass => "pass",
70            Self::Warn => "warn",
71            Self::Fail => "fail",
72            Self::Skip => "skip",
73        }
74    }
75}
76
77/// Top-level rollup classification.
78///
79/// `exact` = no warnings or failures and no simulated subjects.
80/// `compatible` = warnings present but no failures and no simulated subjects.
81/// `simulated` = at least one subject is simulated (expression-bearing image,
82///     missing remote action manifest, image pull required while offline, etc.).
83/// `unsupported` = at least one subject failed outright (non-Linux job container,
84///     `--network` in options, missing required image, missing Dockerfile, etc.).
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
86#[serde(rename_all = "lowercase")]
87pub enum Compatibility {
88    Exact,
89    Compatible,
90    Simulated,
91    Unsupported,
92}
93
94impl Compatibility {
95    /// The worse of `self` and `other`. `Unsupported > Simulated > Compatible > Exact`.
96    pub fn worse(self, other: Self) -> Self {
97        use Compatibility::*;
98        match (self, other) {
99            (Unsupported, _) | (_, Unsupported) => Unsupported,
100            (Simulated, _) | (_, Simulated) => Simulated,
101            (Compatible, _) | (_, Compatible) => Compatible,
102            _ => Exact,
103        }
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
108#[serde(rename_all = "kebab-case")]
109pub enum NetworkModel {
110    /// ci-forge owns container networking; user `--network` is rejected.
111    CiForgeManaged,
112    /// Docker default bridge networking; no networking concerns flagged.
113    DockerDefault,
114    /// Workflow attempted to set unsupported custom networking.
115    UnsupportedCustom,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
119#[serde(rename_all = "kebab-case")]
120pub enum SubjectKind {
121    JobContainer,
122    DockerAction,
123    DockerProbe,
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
127pub struct ReceiptSummary {
128    pub passed: usize,
129    pub warnings: usize,
130    pub failed: usize,
131    pub skipped: usize,
132}
133
134impl ReceiptSummary {
135    pub fn from_checks(checks: &[Check]) -> Self {
136        let mut summary = Self::default();
137        for check in checks {
138            summary.tally(check.status);
139        }
140        summary
141    }
142
143    pub fn tally(&mut self, status: CheckStatus) {
144        match status {
145            CheckStatus::Pass => self.passed += 1,
146            CheckStatus::Warn => self.warnings += 1,
147            CheckStatus::Fail => self.failed += 1,
148            CheckStatus::Skip => self.skipped += 1,
149        }
150    }
151
152    pub fn add(&mut self, other: &Self) {
153        self.passed += other.passed;
154        self.warnings += other.warnings;
155        self.failed += other.failed;
156        self.skipped += other.skipped;
157    }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub struct Check {
162    pub id: String,
163    pub status: CheckStatus,
164    pub message: String,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub location: Option<String>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub details: Option<Value>,
169}
170
171impl Check {
172    pub fn new(id: impl Into<String>, status: CheckStatus, message: impl Into<String>) -> Self {
173        Self {
174            id: id.into(),
175            status,
176            message: message.into(),
177            location: None,
178            details: None,
179        }
180    }
181
182    pub fn pass(id: impl Into<String>, message: impl Into<String>) -> Self {
183        Self::new(id, CheckStatus::Pass, message)
184    }
185
186    pub fn warn(id: impl Into<String>, message: impl Into<String>) -> Self {
187        Self::new(id, CheckStatus::Warn, message)
188    }
189
190    pub fn fail(id: impl Into<String>, message: impl Into<String>) -> Self {
191        Self::new(id, CheckStatus::Fail, message)
192    }
193
194    pub fn skip(id: impl Into<String>, message: impl Into<String>) -> Self {
195        Self::new(id, CheckStatus::Skip, message)
196    }
197
198    pub fn at(mut self, location: impl Into<String>) -> Self {
199        self.location = Some(location.into());
200        self
201    }
202
203    pub fn with_details(mut self, details: Value) -> Self {
204        self.details = Some(details);
205        self
206    }
207}
208
209/// One container subject in a receipt: a job container, a Docker action, or a
210/// Docker probe.
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212pub struct Subject {
213    pub kind: SubjectKind,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub job_id: Option<String>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub step_id: Option<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub action_ref: Option<String>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub image: Option<String>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub dockerfile: Option<String>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub runner_os: Option<RunnerOs>,
226    pub classification: Compatibility,
227    pub network_model: NetworkModel,
228    pub requires_docker: bool,
229    pub requires_build: bool,
230    pub requires_pull: bool,
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub credentials_redacted: Vec<String>,
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub env_redacted: Vec<String>,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub probe: Option<ProbeReport>,
237    pub summary: ReceiptSummary,
238    pub checks: Vec<Check>,
239}
240
241impl Subject {
242    pub fn new(kind: SubjectKind) -> Self {
243        Self {
244            kind,
245            job_id: None,
246            step_id: None,
247            action_ref: None,
248            image: None,
249            dockerfile: None,
250            runner_os: None,
251            classification: Compatibility::Exact,
252            network_model: NetworkModel::DockerDefault,
253            requires_docker: false,
254            requires_build: false,
255            requires_pull: false,
256            credentials_redacted: Vec::new(),
257            env_redacted: Vec::new(),
258            probe: None,
259            summary: ReceiptSummary::default(),
260            checks: Vec::new(),
261        }
262    }
263
264    pub fn push(&mut self, check: Check) {
265        self.summary.tally(check.status);
266        self.checks.push(check);
267    }
268
269    /// Recompute `summary` from `checks` and possibly downgrade `classification`
270    /// based on observed warnings/failures. Failures always force `Unsupported`;
271    /// warnings nudge `Exact` to `Compatible` but do not downgrade `Simulated`.
272    pub fn finalize(&mut self) {
273        self.summary = ReceiptSummary::from_checks(&self.checks);
274        if self.summary.failed > 0 {
275            self.classification = Compatibility::Unsupported;
276        } else if self.classification == Compatibility::Exact && self.summary.warnings > 0 {
277            self.classification = Compatibility::Compatible;
278        }
279    }
280}
281
282/// Probe-specific evidence attached to a `docker-probe` subject.
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284pub struct ProbeReport {
285    pub docker_cli_available: bool,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub docker_bin: Option<String>,
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub inspect: Option<ProbeStep>,
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub tools: Vec<ProbeStep>,
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub commands: Vec<ProbeStep>,
294}
295
296impl ProbeReport {
297    pub fn new() -> Self {
298        Self {
299            docker_cli_available: false,
300            docker_bin: None,
301            inspect: None,
302            tools: Vec::new(),
303            commands: Vec::new(),
304        }
305    }
306}
307
308impl Default for ProbeReport {
309    fn default() -> Self {
310        Self::new()
311    }
312}
313
314/// One step of probe evidence: the docker command that was invoked, its exit
315/// code, and excerpts of stdout/stderr.
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
317pub struct ProbeStep {
318    pub kind: ProbeStepKind,
319    pub command: String,
320    pub success: bool,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub exit_code: Option<i32>,
323    pub elapsed_ms: u128,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub stdout: Option<String>,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub stderr: Option<String>,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub spawn_error: Option<String>,
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
333#[serde(rename_all = "kebab-case")]
334pub enum ProbeStepKind {
335    Inspect,
336    Tool,
337    Command,
338    Pull,
339}
340
341/// The top-level receipt. The same shape is emitted by all four commands.
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
343pub struct ContainerProofReceipt {
344    pub schema_version: SchemaVersion,
345    pub tool: ToolInfo,
346    pub checked_at: DateTime<Utc>,
347    pub mode: String,
348    pub compatibility: Compatibility,
349    pub summary: ReceiptSummary,
350    #[serde(default, skip_serializing_if = "Vec::is_empty")]
351    pub subjects: Vec<Subject>,
352    #[serde(default, skip_serializing_if = "Vec::is_empty")]
353    pub checks: Vec<Check>,
354}
355
356impl ContainerProofReceipt {
357    /// Construct a receipt from a mode label and a list of subjects, plus any
358    /// receipt-level checks (e.g. workflow-scan rollups). Aggregates summary
359    /// and compatibility automatically.
360    pub fn build(
361        mode: impl Into<String>,
362        mut subjects: Vec<Subject>,
363        receipt_checks: Vec<Check>,
364    ) -> Self {
365        for subject in &mut subjects {
366            subject.finalize();
367        }
368
369        let mut summary = ReceiptSummary::from_checks(&receipt_checks);
370        for subject in &subjects {
371            summary.add(&subject.summary);
372        }
373
374        let compatibility = subjects
375            .iter()
376            .map(|subject| subject.classification)
377            .fold(Compatibility::Exact, Compatibility::worse);
378
379        let compatibility = if subjects.is_empty() && summary.failed == 0 {
380            // Workflow scans with nothing to classify still report Exact.
381            Compatibility::Exact
382        } else {
383            compatibility
384        };
385
386        Self {
387            schema_version: SCHEMA_VERSION,
388            tool: ToolInfo {
389                name: crate::TOOL_NAME.to_owned(),
390                version: crate::TOOL_VERSION.to_owned(),
391            },
392            checked_at: Utc::now(),
393            mode: mode.into(),
394            compatibility,
395            summary,
396            subjects,
397            checks: receipt_checks,
398        }
399    }
400
401    /// True when no check is `fail`, used by the CLI to decide exit code. With
402    /// `strict`, warnings are treated as failures.
403    pub fn is_success(&self, strict: bool) -> bool {
404        self.summary.failed == 0 && (!strict || self.summary.warnings == 0)
405    }
406}
407
408/// Return `true` for environment-variable keys that look secret-ish.
409pub fn is_sensitive_key(key: &str) -> bool {
410    let upper = key.to_ascii_uppercase();
411    [
412        "PASSWORD",
413        "PASS",
414        "SECRET",
415        "TOKEN",
416        "CREDENTIAL",
417        "API_KEY",
418        "ACCESS_KEY",
419        "PRIVATE_KEY",
420    ]
421    .iter()
422    .any(|needle| upper.contains(needle))
423        || (upper.contains("KEY") && !upper.starts_with("KEYWORD"))
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn compatibility_worse_orders_correctly() {
432        use Compatibility::*;
433        assert_eq!(Exact.worse(Compatible), Compatible);
434        assert_eq!(Compatible.worse(Simulated), Simulated);
435        assert_eq!(Simulated.worse(Unsupported), Unsupported);
436        assert_eq!(Unsupported.worse(Exact), Unsupported);
437    }
438
439    #[test]
440    fn subject_finalize_promotes_failures_to_unsupported() {
441        let mut subject = Subject::new(SubjectKind::JobContainer);
442        subject.classification = Compatibility::Exact;
443        subject.push(Check::fail("x", "broken"));
444        subject.finalize();
445        assert_eq!(subject.classification, Compatibility::Unsupported);
446        assert_eq!(subject.summary.failed, 1);
447    }
448
449    #[test]
450    fn subject_finalize_promotes_warnings_to_compatible() {
451        let mut subject = Subject::new(SubjectKind::JobContainer);
452        subject.classification = Compatibility::Exact;
453        subject.push(Check::warn("x", "iffy"));
454        subject.finalize();
455        assert_eq!(subject.classification, Compatibility::Compatible);
456        assert_eq!(subject.summary.warnings, 1);
457    }
458
459    #[test]
460    fn subject_finalize_keeps_simulated_with_warnings() {
461        let mut subject = Subject::new(SubjectKind::DockerAction);
462        subject.classification = Compatibility::Simulated;
463        subject.push(Check::warn("x", "iffy"));
464        subject.finalize();
465        assert_eq!(subject.classification, Compatibility::Simulated);
466    }
467
468    #[test]
469    fn receipt_build_rolls_up_compatibility() {
470        let mut subject = Subject::new(SubjectKind::JobContainer);
471        subject.classification = Compatibility::Simulated;
472        let receipt = ContainerProofReceipt::build("plan-job", vec![subject], Vec::new());
473        assert_eq!(receipt.compatibility, Compatibility::Simulated);
474    }
475
476    #[test]
477    fn is_sensitive_key_catches_common_shapes() {
478        assert!(is_sensitive_key("DATABASE_PASSWORD"));
479        assert!(is_sensitive_key("github_token"));
480        assert!(is_sensitive_key("API_SECRET"));
481        assert!(is_sensitive_key("MY_PRIVATE_KEY"));
482        assert!(!is_sensitive_key("NODE_ENV"));
483        assert!(!is_sensitive_key("KEYWORD"));
484    }
485}