Skip to main content

gha_github_service_proof/
render.rs

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}