gha_github_service_proof/
gh_log.rs1use anyhow::{Context, Result, bail};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6use crate::call::{ClassifyOptions, classify};
7use crate::model::{
8 Check, GH_LOG_SCHEMA_VERSION, GhLogReport, PermissionSet, ReceiptSummary, ToolInfo,
9};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct GhLogBundle {
13 pub schema_version: u32,
14 pub tool: BundleTool,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub captured_at: Option<DateTime<Utc>>,
17 #[serde(default)]
18 pub calls: Vec<GhLogCall>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct BundleTool {
23 pub name: String,
24 pub version: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct GhLogCall {
29 pub id: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub timestamp: Option<DateTime<Utc>>,
32 pub source: String,
33 pub method: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub url: Option<String>,
36 pub path: String,
37 #[serde(default)]
38 pub request_headers: BTreeMap<String, String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub request_body_excerpt: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub status: Option<u16>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub response_body_excerpt: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub exit_code: Option<i32>,
47}
48
49#[derive(Debug, Clone)]
50pub struct ReplayOptions {
51 pub bundle: GhLogBundle,
52 pub permissions: Option<PermissionSet>,
53 pub unsafe_full_payloads: bool,
54}
55
56pub fn parse_bundle(raw: &str) -> Result<GhLogBundle> {
57 let bundle: GhLogBundle =
58 serde_json::from_str(raw).with_context(|| "parsing gh-log JSON bundle")?;
59 if bundle.schema_version != GH_LOG_SCHEMA_VERSION {
60 bail!(
61 "gh-log schema_version {} is not supported by gha-github-service-proof v1 (expected {})",
62 bundle.schema_version,
63 GH_LOG_SCHEMA_VERSION
64 );
65 }
66 if bundle.tool.name.trim().is_empty() {
67 bail!("gh-log tool.name must be non-empty");
68 }
69 if bundle.tool.version.trim().is_empty() {
70 bail!("gh-log tool.version must be non-empty");
71 }
72 Ok(bundle)
73}
74
75pub fn replay(options: ReplayOptions) -> GhLogReport {
76 let bundle = options.bundle;
77 let permissions = options.permissions;
78 let unsafe_full_payloads = options.unsafe_full_payloads;
79
80 let mut checks = Vec::new();
81 let mut summary = ReceiptSummary::default();
82 let mut calls = Vec::new();
83 let mut redaction_enforced = !unsafe_full_payloads;
84
85 if unsafe_full_payloads {
86 checks.push(Check::warn(
87 "gh_log.unsafe_full_payloads",
88 "redaction-by-schema-contract is disabled (--unsafe-full-payloads). Bodies and authorization headers will not be checked. Use only for local debugging.",
89 ));
90 }
91
92 if bundle.calls.is_empty() {
93 checks.push(Check::warn(
94 "gh_log.no_calls",
95 "gh-log bundle contains zero captured calls",
96 ));
97 }
98
99 for call in &bundle.calls {
100 let location = format!("call {} ({})", call.id, call.source);
101 if !unsafe_full_payloads {
102 match find_header(&call.request_headers, "authorization") {
103 Some("<redacted>") => {}
104 Some(value) => {
105 let message = format!(
106 "call {} authorization header is not '<redacted>'; gh-log schema contract requires redacted captures unless --unsafe-full-payloads is set (header bytes were {} chars)",
107 call.id,
108 value.len()
109 );
110 checks.push(
111 Check::fail("gh_log.authorization_not_redacted", message)
112 .at(location.clone()),
113 );
114 redaction_enforced = false;
115 }
116 None => {
117 checks.push(Check::warn(
118 "gh_log.authorization_absent",
119 format!(
120 "call {} has no authorization header; assume unauthenticated or redacted upstream",
121 call.id
122 ),
123 ).at(location.clone()));
124 }
125 }
126 }
127
128 let mut report = classify(ClassifyOptions {
129 method: call.method.clone(),
130 path: call.path.clone(),
131 url: call.url.clone(),
132 origin: Some(location.clone()),
133 permissions: permissions.clone(),
134 });
135
136 if let Some(status) = call.status {
137 if status >= 400 {
138 report.checks.push(
139 Check::warn(
140 "gh_log.upstream_error",
141 format!(
142 "captured response status is {status}; ci-forge classification reflects request shape, not response failure",
143 ),
144 )
145 .at(location.clone()),
146 );
147 }
148 }
149
150 summary.merge_checks(&report.checks);
151 calls.push(report);
152 }
153
154 summary.add(&ReceiptSummary::from_checks(&checks));
155
156 GhLogReport {
157 schema_version: bundle.schema_version,
158 tool: ToolInfo {
159 name: bundle.tool.name,
160 version: bundle.tool.version,
161 },
162 captured_at: bundle.captured_at,
163 call_count: calls.len(),
164 redaction_enforced,
165 calls,
166 summary,
167 checks,
168 }
169}
170
171fn find_header<'a>(headers: &'a BTreeMap<String, String>, name: &str) -> Option<&'a str> {
172 for (key, value) in headers {
173 if key.eq_ignore_ascii_case(name) {
174 return Some(value.as_str());
175 }
176 }
177 None
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::model::{CheckStatus, PermissionKey, PermissionLevel};
184
185 fn perms_with(key: PermissionKey, level: PermissionLevel) -> PermissionSet {
186 let mut set = PermissionSet::default();
187 set.entries.insert(key.as_str().to_owned(), level);
188 set
189 }
190
191 fn sample_bundle(authorization: &str) -> GhLogBundle {
192 let mut headers = BTreeMap::new();
193 headers.insert(
194 "accept".to_owned(),
195 "application/vnd.github+json".to_owned(),
196 );
197 headers.insert("authorization".to_owned(), authorization.to_owned());
198 GhLogBundle {
199 schema_version: 1,
200 tool: BundleTool {
201 name: "ci-forge".to_owned(),
202 version: "0.1.0".to_owned(),
203 },
204 captured_at: None,
205 calls: vec![GhLogCall {
206 id: "call-1".to_owned(),
207 timestamp: None,
208 source: "gh".to_owned(),
209 method: "POST".to_owned(),
210 url: Some("https://api.github.com/repos/wildmason/mortar/releases".to_owned()),
211 path: "/repos/wildmason/mortar/releases".to_owned(),
212 request_headers: headers,
213 request_body_excerpt: Some("{}".to_owned()),
214 status: Some(201),
215 response_body_excerpt: Some("{\"id\":123}".to_owned()),
216 exit_code: Some(0),
217 }],
218 }
219 }
220
221 #[test]
222 fn redacted_capture_classifies_call_and_keeps_redaction_enforced() {
223 let bundle = sample_bundle("<redacted>");
224 let report = replay(ReplayOptions {
225 bundle,
226 permissions: Some(perms_with(PermissionKey::Contents, PermissionLevel::Write)),
227 unsafe_full_payloads: false,
228 });
229 assert_eq!(report.call_count, 1);
230 assert!(report.redaction_enforced);
231 let call = &report.calls[0];
232 assert!(call.satisfied);
233 assert!(call.catalog_match.is_some());
234 }
235
236 #[test]
237 fn unredacted_authorization_fails_when_safe_mode() {
238 let bundle = sample_bundle("Bearer ghp_real_secret_value");
239 let report = replay(ReplayOptions {
240 bundle,
241 permissions: Some(perms_with(PermissionKey::Contents, PermissionLevel::Write)),
242 unsafe_full_payloads: false,
243 });
244 assert!(!report.redaction_enforced);
245 assert!(
246 report
247 .checks
248 .iter()
249 .any(|c| c.id == "gh_log.authorization_not_redacted"
250 && matches!(c.status, CheckStatus::Fail))
251 );
252 }
253
254 #[test]
255 fn unsafe_mode_permits_unredacted_authorization_but_warns() {
256 let bundle = sample_bundle("Bearer ghp_real_secret_value");
257 let report = replay(ReplayOptions {
258 bundle,
259 permissions: Some(perms_with(PermissionKey::Contents, PermissionLevel::Write)),
260 unsafe_full_payloads: true,
261 });
262 assert!(!report.redaction_enforced);
264 assert!(report.checks.iter().any(
265 |c| c.id == "gh_log.unsafe_full_payloads" && matches!(c.status, CheckStatus::Warn)
266 ));
267 assert!(
268 !report
269 .checks
270 .iter()
271 .any(|c| c.id == "gh_log.authorization_not_redacted")
272 );
273 }
274
275 #[test]
276 fn schema_version_mismatch_is_rejected() {
277 let raw = r#"{"schema_version":99,"tool":{"name":"x","version":"y"},"calls":[]}"#;
278 let err = parse_bundle(raw).unwrap_err();
279 assert!(err.to_string().contains("schema_version"));
280 }
281
282 #[test]
283 fn empty_call_list_warns() {
284 let bundle = GhLogBundle {
285 schema_version: 1,
286 tool: BundleTool {
287 name: "ci-forge".to_owned(),
288 version: "0.1.0".to_owned(),
289 },
290 captured_at: None,
291 calls: Vec::new(),
292 };
293 let report = replay(ReplayOptions {
294 bundle,
295 permissions: None,
296 unsafe_full_payloads: false,
297 });
298 assert!(report.checks.iter().any(|c| c.id == "gh_log.no_calls"));
299 }
300}