gha_github_service_proof/
engine.rs1use 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) {}