Skip to main content

gha_github_service_proof/
engine.rs

1use anyhow::{Context, Result, bail};
2use camino::{Utf8Path, Utf8PathBuf};
3use chrono::Utc;
4use serde_json::Value;
5use std::collections::BTreeMap;
6use std::fs;
7
8use crate::call::{ClassifyOptions, classify};
9use crate::gh_log;
10use crate::model::{
11    CallReport, Check, GithubServiceReceipt, OidcReport, PermissionScope, PermissionSet,
12    ReceiptSummary, SCHEMA_VERSION, ToolInfo,
13};
14use crate::oidc::{self, IssueOptions};
15use crate::permissions;
16use crate::workflow;
17use crate::{TOOL_NAME, TOOL_VERSION};
18
19#[derive(Debug, Clone)]
20pub struct CheckWorkflowOptions {
21    pub repo_root: Utf8PathBuf,
22    pub workflows: Vec<Utf8PathBuf>,
23}
24
25pub fn check_workflows(options: &CheckWorkflowOptions) -> Result<GithubServiceReceipt> {
26    let workflows = workflow::scan_workflows(&options.repo_root, &options.workflows)?;
27    let mut summary = ReceiptSummary::default();
28    for workflow in &workflows {
29        summary.add(&workflow.summary);
30    }
31    Ok(GithubServiceReceipt {
32        schema_version: SCHEMA_VERSION,
33        tool: tool_info(),
34        checked_at: Utc::now(),
35        mode: "check-workflow".to_owned(),
36        summary,
37        permissions: None,
38        workflows,
39        calls: Vec::new(),
40        oidc: None,
41        gh_log: None,
42        checks: Vec::new(),
43    })
44}
45
46#[derive(Debug, Clone)]
47pub struct PermissionsOptions {
48    pub workflow_path: Utf8PathBuf,
49    pub job: Option<String>,
50}
51
52pub fn permissions_command(options: &PermissionsOptions) -> Result<GithubServiceReceipt> {
53    let text = fs::read_to_string(&options.workflow_path)
54        .with_context(|| format!("reading workflow {}", options.workflow_path))?;
55    let stripped = text.strip_prefix('\u{feff}').unwrap_or(&text);
56    let value: serde_yaml::Value = serde_yaml::from_str(stripped)
57        .with_context(|| format!("parsing YAML {}", options.workflow_path))?;
58    let serde_yaml::Value::Mapping(root) = value else {
59        bail!("workflow root must be a YAML mapping");
60    };
61
62    let workflow_yaml = mapping_get(&root, "permissions");
63    let (workflow_permissions, mut all_checks) = permissions::parse_yaml_block(workflow_yaml);
64
65    let (job_permissions, scope) = if let Some(job_id) = &options.job {
66        let jobs = mapping_get(&root, "jobs");
67        let Some(serde_yaml::Value::Mapping(jobs_map)) = jobs else {
68            bail!("workflow has no `jobs:` mapping");
69        };
70        let Some(serde_yaml::Value::Mapping(job_map)) = mapping_get(jobs_map, job_id) else {
71            bail!("job '{job_id}' not found in workflow");
72        };
73        let job_yaml = mapping_get(job_map, "permissions");
74        let (job_perms, job_checks) = permissions::parse_yaml_block(job_yaml);
75        all_checks.extend(job_checks);
76        (job_perms, PermissionScope::Job)
77    } else {
78        (None, PermissionScope::Workflow)
79    };
80
81    let resolution = permissions::resolve(workflow_permissions, job_permissions, scope);
82    let mut summary = ReceiptSummary::from_checks(&all_checks);
83    summary.merge_checks(&resolution.checks);
84
85    Ok(GithubServiceReceipt {
86        schema_version: SCHEMA_VERSION,
87        tool: tool_info(),
88        checked_at: Utc::now(),
89        mode: "permissions".to_owned(),
90        summary,
91        permissions: Some(resolution),
92        workflows: Vec::new(),
93        calls: Vec::new(),
94        oidc: None,
95        gh_log: None,
96        checks: all_checks,
97    })
98}
99
100#[derive(Debug, Clone)]
101pub struct CallOptions {
102    pub method: String,
103    pub path: String,
104    pub url: Option<String>,
105    pub permissions: Option<PermissionSet>,
106    pub origin: Option<String>,
107}
108
109pub fn classify_call(options: &CallOptions) -> Result<GithubServiceReceipt> {
110    let report = classify(ClassifyOptions {
111        method: options.method.clone(),
112        path: options.path.clone(),
113        url: options.url.clone(),
114        origin: options.origin.clone(),
115        permissions: options.permissions.clone(),
116    });
117    let summary = ReceiptSummary::from_checks(&report.checks);
118    Ok(GithubServiceReceipt {
119        schema_version: SCHEMA_VERSION,
120        tool: tool_info(),
121        checked_at: Utc::now(),
122        mode: "call".to_owned(),
123        summary,
124        permissions: None,
125        workflows: Vec::new(),
126        calls: vec![report],
127        oidc: None,
128        gh_log: None,
129        checks: Vec::new(),
130    })
131}
132
133#[derive(Debug, Clone)]
134pub struct OidcOptions {
135    pub audience: String,
136    pub repository: String,
137    pub git_ref: String,
138    pub sha: String,
139    pub workflow: String,
140    pub job: String,
141    pub run_id: String,
142    pub job_workflow_ref: Option<String>,
143    pub permissions: Option<PermissionSet>,
144    pub ttl_seconds: Option<i64>,
145    pub extra_claims: BTreeMap<String, Value>,
146}
147
148pub fn issue_oidc(options: &OidcOptions) -> Result<GithubServiceReceipt> {
149    let report: OidcReport = oidc::issue(IssueOptions {
150        audience: options.audience.clone(),
151        repository: options.repository.clone(),
152        git_ref: options.git_ref.clone(),
153        sha: options.sha.clone(),
154        workflow: options.workflow.clone(),
155        job: options.job.clone(),
156        run_id: options.run_id.clone(),
157        job_workflow_ref: options.job_workflow_ref.clone(),
158        permissions: options.permissions.clone(),
159        now: None,
160        ttl_seconds: options.ttl_seconds,
161        extra_claims: options.extra_claims.clone(),
162    });
163    let mut summary = ReceiptSummary::from_checks(&report.checks);
164    summary.merge_checks(&[]);
165    Ok(GithubServiceReceipt {
166        schema_version: SCHEMA_VERSION,
167        tool: tool_info(),
168        checked_at: Utc::now(),
169        mode: "oidc".to_owned(),
170        summary,
171        permissions: None,
172        workflows: Vec::new(),
173        calls: Vec::new(),
174        oidc: Some(report),
175        gh_log: None,
176        checks: Vec::new(),
177    })
178}
179
180#[derive(Debug, Clone)]
181pub struct GhLogOptions {
182    pub log_path: Utf8PathBuf,
183    pub permissions: Option<PermissionSet>,
184    pub unsafe_full_payloads: bool,
185}
186
187pub fn replay_gh_log(options: &GhLogOptions) -> Result<GithubServiceReceipt> {
188    let raw = fs::read_to_string(&options.log_path)
189        .with_context(|| format!("reading gh-log bundle {}", options.log_path))?;
190    let bundle = gh_log::parse_bundle(&raw)?;
191    let report = gh_log::replay(gh_log::ReplayOptions {
192        bundle,
193        permissions: options.permissions.clone(),
194        unsafe_full_payloads: options.unsafe_full_payloads,
195    });
196    let summary = report.summary.clone();
197    Ok(GithubServiceReceipt {
198        schema_version: SCHEMA_VERSION,
199        tool: tool_info(),
200        checked_at: Utc::now(),
201        mode: "gh-log".to_owned(),
202        summary,
203        permissions: None,
204        workflows: Vec::new(),
205        calls: Vec::new(),
206        oidc: None,
207        gh_log: Some(report),
208        checks: Vec::new(),
209    })
210}
211
212fn mapping_get<'a>(map: &'a serde_yaml::Mapping, key: &str) -> Option<&'a serde_yaml::Value> {
213    for (k, v) in map {
214        if let serde_yaml::Value::String(s) = k {
215            if s == key {
216                return Some(v);
217            }
218        }
219    }
220    None
221}
222
223fn tool_info() -> ToolInfo {
224    ToolInfo {
225        name: TOOL_NAME.to_owned(),
226        version: TOOL_VERSION.to_owned(),
227    }
228}
229
230pub fn check_repo_root(path: &Utf8Path) -> Result<()> {
231    if !path.is_dir() {
232        bail!("--repo must be an existing directory: {path}");
233    }
234    Ok(())
235}
236
237#[allow(dead_code)]
238pub fn enrich_call_with_location(call: &mut CallReport, location: String) {
239    if call.origin.is_none() {
240        call.origin = Some(location);
241    }
242}
243
244#[allow(dead_code)]
245fn ensure_call_has_check(_: &Check) {}