Skip to main content

gha_github_service_proof/
model.rs

1use camino::Utf8PathBuf;
2use chrono::{DateTime, Utc};
3use clap::ValueEnum;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7pub type SchemaVersion = u32;
8pub const SCHEMA_VERSION: SchemaVersion = 1;
9pub const GH_LOG_SCHEMA_VERSION: SchemaVersion = 1;
10
11#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "lowercase")]
13pub enum OutputFormat {
14    Text,
15    Json,
16    Markdown,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ToolInfo {
21    pub name: String,
22    pub version: String,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum CheckStatus {
28    Pass,
29    Warn,
30    Fail,
31    Skip,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Check {
36    pub id: String,
37    pub status: CheckStatus,
38    pub message: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub location: Option<String>,
41}
42
43impl Check {
44    pub fn pass(id: impl Into<String>, message: impl Into<String>) -> Self {
45        Self {
46            id: id.into(),
47            status: CheckStatus::Pass,
48            message: message.into(),
49            location: None,
50        }
51    }
52
53    pub fn warn(id: impl Into<String>, message: impl Into<String>) -> Self {
54        Self {
55            id: id.into(),
56            status: CheckStatus::Warn,
57            message: message.into(),
58            location: None,
59        }
60    }
61
62    pub fn fail(id: impl Into<String>, message: impl Into<String>) -> Self {
63        Self {
64            id: id.into(),
65            status: CheckStatus::Fail,
66            message: message.into(),
67            location: None,
68        }
69    }
70
71    pub fn skip(id: impl Into<String>, message: impl Into<String>) -> Self {
72        Self {
73            id: id.into(),
74            status: CheckStatus::Skip,
75            message: message.into(),
76            location: None,
77        }
78    }
79
80    pub fn at(mut self, location: impl Into<String>) -> Self {
81        self.location = Some(location.into());
82        self
83    }
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87pub struct ReceiptSummary {
88    pub passed: usize,
89    pub warnings: usize,
90    pub failed: usize,
91    pub skipped: usize,
92}
93
94impl ReceiptSummary {
95    pub fn from_checks(checks: &[Check]) -> Self {
96        let mut summary = Self::default();
97        for check in checks {
98            match check.status {
99                CheckStatus::Pass => summary.passed += 1,
100                CheckStatus::Warn => summary.warnings += 1,
101                CheckStatus::Fail => summary.failed += 1,
102                CheckStatus::Skip => summary.skipped += 1,
103            }
104        }
105        summary
106    }
107
108    pub fn add(&mut self, other: &Self) {
109        self.passed += other.passed;
110        self.warnings += other.warnings;
111        self.failed += other.failed;
112        self.skipped += other.skipped;
113    }
114
115    pub fn merge_checks(&mut self, checks: &[Check]) {
116        let other = Self::from_checks(checks);
117        self.add(&other);
118    }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
122#[serde(rename_all = "kebab-case")]
123pub enum PermissionKey {
124    Actions,
125    Attestations,
126    Checks,
127    Contents,
128    Deployments,
129    Discussions,
130    IdToken,
131    Issues,
132    Models,
133    Packages,
134    Pages,
135    PullRequests,
136    RepositoryProjects,
137    SecurityEvents,
138    Statuses,
139}
140
141impl PermissionKey {
142    pub fn as_str(&self) -> &'static str {
143        match self {
144            Self::Actions => "actions",
145            Self::Attestations => "attestations",
146            Self::Checks => "checks",
147            Self::Contents => "contents",
148            Self::Deployments => "deployments",
149            Self::Discussions => "discussions",
150            Self::IdToken => "id-token",
151            Self::Issues => "issues",
152            Self::Models => "models",
153            Self::Packages => "packages",
154            Self::Pages => "pages",
155            Self::PullRequests => "pull-requests",
156            Self::RepositoryProjects => "repository-projects",
157            Self::SecurityEvents => "security-events",
158            Self::Statuses => "statuses",
159        }
160    }
161
162    pub fn parse(value: &str) -> Option<Self> {
163        match value.trim() {
164            "actions" => Some(Self::Actions),
165            "attestations" => Some(Self::Attestations),
166            "checks" => Some(Self::Checks),
167            "contents" => Some(Self::Contents),
168            "deployments" => Some(Self::Deployments),
169            "discussions" => Some(Self::Discussions),
170            "id-token" => Some(Self::IdToken),
171            "issues" => Some(Self::Issues),
172            "models" => Some(Self::Models),
173            "packages" => Some(Self::Packages),
174            "pages" => Some(Self::Pages),
175            "pull-requests" => Some(Self::PullRequests),
176            "repository-projects" => Some(Self::RepositoryProjects),
177            "security-events" => Some(Self::SecurityEvents),
178            "statuses" => Some(Self::Statuses),
179            _ => None,
180        }
181    }
182
183    pub fn all() -> &'static [PermissionKey] {
184        &[
185            Self::Actions,
186            Self::Attestations,
187            Self::Checks,
188            Self::Contents,
189            Self::Deployments,
190            Self::Discussions,
191            Self::IdToken,
192            Self::Issues,
193            Self::Models,
194            Self::Packages,
195            Self::Pages,
196            Self::PullRequests,
197            Self::RepositoryProjects,
198            Self::SecurityEvents,
199            Self::Statuses,
200        ]
201    }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
205#[serde(rename_all = "kebab-case")]
206pub enum PermissionLevel {
207    None,
208    Read,
209    Write,
210}
211
212impl PermissionLevel {
213    pub fn as_str(&self) -> &'static str {
214        match self {
215            Self::None => "none",
216            Self::Read => "read",
217            Self::Write => "write",
218        }
219    }
220
221    pub fn parse(value: &str) -> Option<Self> {
222        match value.trim() {
223            "none" => Some(Self::None),
224            "read" => Some(Self::Read),
225            "write" => Some(Self::Write),
226            _ => None,
227        }
228    }
229
230    pub fn satisfies(&self, required: PermissionLevel) -> bool {
231        *self >= required
232    }
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
236pub struct PermissionSet {
237    pub entries: BTreeMap<String, PermissionLevel>,
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    pub unknown_keys: Vec<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub shorthand: Option<String>,
242}
243
244impl PermissionSet {
245    pub fn level(&self, key: PermissionKey) -> PermissionLevel {
246        self.entries
247            .get(key.as_str())
248            .copied()
249            .unwrap_or(PermissionLevel::None)
250    }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct PermissionResolution {
255    pub scope: PermissionScope,
256    pub workflow_permissions: Option<PermissionSet>,
257    pub job_permissions: Option<PermissionSet>,
258    pub effective: PermissionSet,
259    pub source: PermissionSource,
260    pub checks: Vec<Check>,
261}
262
263#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
264#[serde(rename_all = "kebab-case")]
265pub enum PermissionScope {
266    Workflow,
267    Job,
268}
269
270#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
271#[serde(rename_all = "kebab-case")]
272pub enum PermissionSource {
273    JobBlock,
274    WorkflowBlock,
275    DefaultRestricted,
276}
277
278#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
279#[serde(rename_all = "kebab-case")]
280pub enum Compatibility {
281    Exact,
282    Simulated,
283    Unsupported,
284}
285
286impl Compatibility {
287    pub fn as_str(&self) -> &'static str {
288        match self {
289            Self::Exact => "exact",
290            Self::Simulated => "simulated",
291            Self::Unsupported => "unsupported",
292        }
293    }
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct CatalogMatch {
298    pub endpoint_id: String,
299    pub method: String,
300    pub path_template: String,
301    pub category: String,
302    pub required_permissions: Vec<RequiredPermission>,
303    pub classification: Compatibility,
304    pub side_effect: String,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct RequiredPermission {
309    pub key: PermissionKey,
310    pub level: PermissionLevel,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct CallReport {
315    pub method: String,
316    pub path: String,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub url: Option<String>,
319    pub classification: Compatibility,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub catalog_match: Option<CatalogMatch>,
322    pub unsupported_reason: Option<String>,
323    pub permissions: Option<PermissionSet>,
324    pub satisfied: bool,
325    pub missing_permissions: Vec<RequiredPermission>,
326    pub checks: Vec<Check>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub origin: Option<String>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct WorkflowReport {
333    pub workflow: Utf8PathBuf,
334    pub workflow_permissions: Option<PermissionSet>,
335    pub jobs: Vec<JobApiReport>,
336    pub summary: ReceiptSummary,
337    pub checks: Vec<Check>,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct JobApiReport {
342    pub job_id: String,
343    pub permissions: PermissionResolution,
344    pub steps: Vec<StepApiReport>,
345    pub summary: ReceiptSummary,
346    pub checks: Vec<Check>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct StepApiReport {
351    pub step_index: usize,
352    pub step_name: Option<String>,
353    pub uses: Option<String>,
354    pub detections: Vec<ApiDetection>,
355    pub checks: Vec<Check>,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct ApiDetection {
360    pub origin: ApiDetectionOrigin,
361    pub label: String,
362    pub method: String,
363    pub path: String,
364    pub classification: Compatibility,
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub catalog_match: Option<CatalogMatch>,
367    pub satisfied: bool,
368    pub missing_permissions: Vec<RequiredPermission>,
369    pub unsupported_reason: Option<String>,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "kebab-case")]
374pub enum ApiDetectionOrigin {
375    GhCli,
376    Curl,
377    GithubScript,
378    ReleaseAction,
379    OidcAction,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct OidcReport {
384    pub audience: String,
385    pub repository: String,
386    pub git_ref: String,
387    pub sha: String,
388    pub workflow: String,
389    pub job: String,
390    pub job_workflow_ref: String,
391    pub run_id: String,
392    pub iat: i64,
393    pub exp: i64,
394    pub claims: BTreeMap<String, serde_json::Value>,
395    pub token: String,
396    pub signing_mode: String,
397    pub compatibility: Compatibility,
398    pub permissions: Option<PermissionSet>,
399    pub checks: Vec<Check>,
400    pub warning: String,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct GhLogReport {
405    pub schema_version: SchemaVersion,
406    pub tool: ToolInfo,
407    pub captured_at: Option<DateTime<Utc>>,
408    pub call_count: usize,
409    pub redaction_enforced: bool,
410    pub calls: Vec<CallReport>,
411    pub summary: ReceiptSummary,
412    pub checks: Vec<Check>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct GithubServiceReceipt {
417    pub schema_version: SchemaVersion,
418    pub tool: ToolInfo,
419    pub checked_at: DateTime<Utc>,
420    pub mode: String,
421    pub summary: ReceiptSummary,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub permissions: Option<PermissionResolution>,
424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
425    pub workflows: Vec<WorkflowReport>,
426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
427    pub calls: Vec<CallReport>,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub oidc: Option<OidcReport>,
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub gh_log: Option<GhLogReport>,
432    pub checks: Vec<Check>,
433}