1use anyhow::Result;
2
3use crate::model::{
4 ApiDetection, CallReport, CheckStatus, GithubServiceReceipt, JobApiReport, OidcReport,
5 OutputFormat, PermissionResolution, StepApiReport, WorkflowReport,
6};
7
8pub fn render_receipt(receipt: &GithubServiceReceipt, format: OutputFormat) -> Result<String> {
9 match format {
10 OutputFormat::Text => Ok(render_text(receipt)),
11 OutputFormat::Json => Ok(format!("{}\n", serde_json::to_string_pretty(receipt)?)),
12 OutputFormat::Markdown => Ok(render_markdown(receipt)),
13 }
14}
15
16fn render_text(receipt: &GithubServiceReceipt) -> String {
17 let mut out = String::new();
18 out.push_str(&format!(
19 "{} {} ({})\n",
20 receipt.tool.name, receipt.tool.version, receipt.mode
21 ));
22 out.push_str(&format!(
23 "summary: {} passed, {} warned, {} failed, {} skipped\n",
24 receipt.summary.passed,
25 receipt.summary.warnings,
26 receipt.summary.failed,
27 receipt.summary.skipped
28 ));
29
30 if let Some(resolution) = &receipt.permissions {
31 out.push_str(&format!(
32 "\npermissions ({:?} from {:?}):\n",
33 resolution.scope, resolution.source
34 ));
35 for (key, level) in &resolution.effective.entries {
36 out.push_str(&format!(" {key}: {}\n", level.as_str()));
37 }
38 if let Some(shorthand) = &resolution.effective.shorthand {
39 out.push_str(&format!(" shorthand: {shorthand}\n"));
40 }
41 for unknown in &resolution.effective.unknown_keys {
42 out.push_str(&format!(" unknown: {unknown}\n"));
43 }
44 for check in &resolution.checks {
45 out.push_str(&format!(
46 " {} {:<5} {}\n",
47 status_symbol(check.status),
48 status_word(check.status),
49 check.message
50 ));
51 }
52 }
53
54 for workflow in &receipt.workflows {
55 render_workflow_text(&mut out, workflow);
56 }
57
58 if !receipt.calls.is_empty() {
59 out.push_str("\ncalls:\n");
60 for call in &receipt.calls {
61 render_call_text(&mut out, call, 2);
62 }
63 }
64
65 if let Some(oidc) = &receipt.oidc {
66 render_oidc_text(&mut out, oidc);
67 }
68
69 if let Some(gh_log) = &receipt.gh_log {
70 out.push_str(&format!(
71 "\ngh-log replay: tool={} v{} captured={} calls (redaction_enforced={})\n",
72 gh_log.tool.name, gh_log.tool.version, gh_log.call_count, gh_log.redaction_enforced
73 ));
74 for call in &gh_log.calls {
75 render_call_text(&mut out, call, 2);
76 }
77 for check in &gh_log.checks {
78 out.push_str(&format!(
79 " {} {:<5} {}\n",
80 status_symbol(check.status),
81 status_word(check.status),
82 check.message
83 ));
84 }
85 }
86
87 if !receipt.checks.is_empty() {
88 out.push_str("\nchecks:\n");
89 for check in &receipt.checks {
90 out.push_str(&format!(
91 " {} {:<5} {}\n",
92 status_symbol(check.status),
93 status_word(check.status),
94 check.message
95 ));
96 }
97 }
98
99 out
100}
101
102fn render_workflow_text(out: &mut String, workflow: &WorkflowReport) {
103 out.push_str(&format!("\nworkflow {}:\n", workflow.workflow));
104 out.push_str(&format!(
105 " summary: {} passed, {} warned, {} failed, {} skipped\n",
106 workflow.summary.passed,
107 workflow.summary.warnings,
108 workflow.summary.failed,
109 workflow.summary.skipped
110 ));
111 for job in &workflow.jobs {
112 render_job_text(out, job);
113 }
114 for check in &workflow.checks {
115 out.push_str(&format!(
116 " {} {:<5} {}\n",
117 status_symbol(check.status),
118 status_word(check.status),
119 check.message
120 ));
121 }
122}
123
124fn render_job_text(out: &mut String, job: &JobApiReport) {
125 out.push_str(&format!(" job {}:\n", job.job_id));
126 render_permission_resolution_text(out, &job.permissions);
127 for step in &job.steps {
128 render_step_text(out, step);
129 }
130 for check in &job.checks {
131 out.push_str(&format!(
132 " {} {:<5} {}\n",
133 status_symbol(check.status),
134 status_word(check.status),
135 check.message
136 ));
137 }
138}
139
140fn render_permission_resolution_text(out: &mut String, resolution: &PermissionResolution) {
141 out.push_str(&format!(
142 " permissions (source: {:?}):\n",
143 resolution.source
144 ));
145 for (key, level) in &resolution.effective.entries {
146 out.push_str(&format!(" {key}: {}\n", level.as_str()));
147 }
148 for check in &resolution.checks {
149 out.push_str(&format!(
150 " {} {:<5} {}\n",
151 status_symbol(check.status),
152 status_word(check.status),
153 check.message
154 ));
155 }
156}
157
158fn render_step_text(out: &mut String, step: &StepApiReport) {
159 let label = step.step_name.clone().unwrap_or_else(|| {
160 step.uses
161 .clone()
162 .unwrap_or_else(|| format!("step {}", step.step_index))
163 });
164 out.push_str(&format!(" step {}: {}\n", step.step_index, label));
165 for detection in &step.detections {
166 render_detection_text(out, detection);
167 }
168 for check in &step.checks {
169 out.push_str(&format!(
170 " {} {:<5} {}\n",
171 status_symbol(check.status),
172 status_word(check.status),
173 check.message
174 ));
175 }
176}
177
178fn render_detection_text(out: &mut String, detection: &ApiDetection) {
179 out.push_str(&format!(
180 " detect: {} {} -> {} ({:?}) [{}]\n",
181 detection.method,
182 detection.path,
183 detection.classification.as_str(),
184 detection.origin,
185 if detection.satisfied {
186 "satisfied"
187 } else {
188 "missing"
189 }
190 ));
191}
192
193fn render_call_text(out: &mut String, call: &CallReport, indent: usize) {
194 let pad = " ".repeat(indent);
195 let classification = call.classification.as_str();
196 out.push_str(&format!(
197 "{pad}{} {} -> {}\n",
198 call.method, call.path, classification
199 ));
200 if let Some(catalog) = &call.catalog_match {
201 out.push_str(&format!(
202 "{pad} catalog: {} ({})\n",
203 catalog.endpoint_id, catalog.category
204 ));
205 } else if let Some(reason) = &call.unsupported_reason {
206 out.push_str(&format!("{pad} reason: {reason}\n"));
207 }
208 for check in &call.checks {
209 out.push_str(&format!(
210 "{pad} {} {:<5} {}\n",
211 status_symbol(check.status),
212 status_word(check.status),
213 check.message
214 ));
215 }
216}
217
218fn render_oidc_text(out: &mut String, oidc: &OidcReport) {
219 out.push_str(&format!(
220 "\noidc: audience={} signing_mode={} compatibility={}\n",
221 oidc.audience,
222 oidc.signing_mode,
223 oidc.compatibility.as_str()
224 ));
225 out.push_str(&format!(
226 " sub: {}\n",
227 oidc.claims
228 .get("sub")
229 .map(value_to_string)
230 .unwrap_or_default()
231 ));
232 out.push_str(&format!(" iat/exp: {}/{}\n", oidc.iat, oidc.exp));
233 out.push_str(&format!(
234 " token: {}...\n",
235 &oidc.token.chars().take(24).collect::<String>()
236 ));
237 out.push_str(&format!(" warning: {}\n", oidc.warning));
238 for check in &oidc.checks {
239 out.push_str(&format!(
240 " {} {:<5} {}\n",
241 status_symbol(check.status),
242 status_word(check.status),
243 check.message
244 ));
245 }
246}
247
248fn render_markdown(receipt: &GithubServiceReceipt) -> String {
249 let mut out = String::new();
250 out.push_str("# gha-github-service-proof Receipt\n\n");
251 out.push_str(&format!(
252 "- Tool: `{}` `{}`\n",
253 receipt.tool.name, receipt.tool.version
254 ));
255 out.push_str(&format!("- Mode: `{}`\n", markdown_escape(&receipt.mode)));
256 out.push_str(&format!("- Checked at: `{}`\n", receipt.checked_at));
257 out.push_str(&format!(
258 "- Summary: **{} passed**, **{} warned**, **{} failed**, **{} skipped**\n\n",
259 receipt.summary.passed,
260 receipt.summary.warnings,
261 receipt.summary.failed,
262 receipt.summary.skipped
263 ));
264
265 if let Some(resolution) = &receipt.permissions {
266 out.push_str("## Permissions\n\n");
267 out.push_str(&format!("- Source: `{:?}`\n", resolution.source));
268 out.push_str("- Effective scopes:\n");
269 for (key, level) in &resolution.effective.entries {
270 out.push_str(&format!(" - `{key}`: `{}`\n", level.as_str()));
271 }
272 if !resolution.effective.unknown_keys.is_empty() {
273 out.push_str("- Unknown keys: ");
274 out.push_str(
275 &resolution
276 .effective
277 .unknown_keys
278 .iter()
279 .map(|k| format!("`{k}`"))
280 .collect::<Vec<_>>()
281 .join(", "),
282 );
283 out.push('\n');
284 }
285 out.push('\n');
286 }
287
288 if !receipt.workflows.is_empty() {
289 out.push_str("## Workflows\n\n");
290 for workflow in &receipt.workflows {
291 out.push_str(&format!(
292 "### `{}`\n\n{} jobs scanned.\n\n",
293 workflow.workflow,
294 workflow.jobs.len()
295 ));
296 out.push_str("| Job | Step | Detection | Classification | Satisfied |\n");
297 out.push_str("| --- | --- | --- | --- | --- |\n");
298 for job in &workflow.jobs {
299 for step in &job.steps {
300 for detection in &step.detections {
301 out.push_str(&format!(
302 "| `{}` | {} | `{} {}` | `{}` | `{}` |\n",
303 markdown_escape(&job.job_id),
304 step.step_index,
305 markdown_escape(&detection.method),
306 markdown_escape(&detection.path),
307 detection.classification.as_str(),
308 detection.satisfied,
309 ));
310 }
311 }
312 }
313 out.push('\n');
314 }
315 }
316
317 if !receipt.calls.is_empty() {
318 out.push_str("## Calls\n\n");
319 out.push_str("| Method | Path | Classification | Satisfied | Catalog |\n");
320 out.push_str("| --- | --- | --- | --- | --- |\n");
321 for call in &receipt.calls {
322 let catalog = call
323 .catalog_match
324 .as_ref()
325 .map(|m| format!("`{}`", m.endpoint_id))
326 .unwrap_or_else(|| {
327 call.unsupported_reason
328 .clone()
329 .map(|r| format!("`{r}`"))
330 .unwrap_or_else(|| "—".to_owned())
331 });
332 out.push_str(&format!(
333 "| `{}` | `{}` | `{}` | `{}` | {} |\n",
334 markdown_escape(&call.method),
335 markdown_escape(&call.path),
336 call.classification.as_str(),
337 call.satisfied,
338 catalog,
339 ));
340 }
341 out.push('\n');
342 }
343
344 if let Some(oidc) = &receipt.oidc {
345 out.push_str("## OIDC\n\n");
346 out.push_str(&format!(
347 "- Audience: `{}`\n- Signing mode: `{}`\n- Compatibility: `{}`\n- iat / exp: `{}` / `{}`\n- Token (truncated): `{}…`\n\n",
348 markdown_escape(&oidc.audience),
349 markdown_escape(&oidc.signing_mode),
350 oidc.compatibility.as_str(),
351 oidc.iat,
352 oidc.exp,
353 markdown_escape(&oidc.token.chars().take(40).collect::<String>()),
354 ));
355 out.push_str(&format!("> ⚠️ {}\n\n", markdown_escape(&oidc.warning)));
356 }
357
358 if let Some(gh_log) = &receipt.gh_log {
359 out.push_str("## gh-log Replay\n\n");
360 out.push_str(&format!(
361 "- Tool: `{}` `{}`\n- Calls: `{}`\n- Redaction enforced: `{}`\n\n",
362 markdown_escape(&gh_log.tool.name),
363 markdown_escape(&gh_log.tool.version),
364 gh_log.call_count,
365 gh_log.redaction_enforced,
366 ));
367 }
368
369 if !receipt.checks.is_empty() {
370 out.push_str("## Checks\n\n");
371 for check in &receipt.checks {
372 out.push_str(&format!(
373 "- `{}` `{}` — {}\n",
374 status_word(check.status),
375 check.id,
376 markdown_escape(&check.message),
377 ));
378 }
379 }
380
381 out
382}
383
384fn value_to_string(value: &serde_json::Value) -> String {
385 match value {
386 serde_json::Value::String(s) => s.clone(),
387 other => other.to_string(),
388 }
389}
390
391fn status_symbol(status: CheckStatus) -> &'static str {
392 match status {
393 CheckStatus::Pass => "[PASS]",
394 CheckStatus::Warn => "[WARN]",
395 CheckStatus::Fail => "[FAIL]",
396 CheckStatus::Skip => "[SKIP]",
397 }
398}
399
400fn status_word(status: CheckStatus) -> &'static str {
401 match status {
402 CheckStatus::Pass => "pass",
403 CheckStatus::Warn => "warn",
404 CheckStatus::Fail => "fail",
405 CheckStatus::Skip => "skip",
406 }
407}
408
409fn markdown_escape(value: &str) -> String {
410 value.replace('|', "\\|").replace('\n', "<br>")
411}