Skip to main content

gha_github_service_proof/
gh_log.rs

1use 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        // redaction_enforced reflects the flag, not header contents
263        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}