Skip to main content

choir_bridge/
github.rs

1//! GitHub App auth + status write-back (D21 write-back stage, risk #15).
2//!
3//! Credential shape: App JWT (RS256, signed by shelling out to
4//! `openssl` against the operator's PEM path — the key bytes never pass
5//! through this process's callers) → short-lived installation token →
6//! commit-status POST. Permissions required of the App: Commit
7//! statuses (read & write) plus, for the queue stage, Pull requests
8//! (read), Checks (read), and Contents (read & write) to publish the
9//! train branch. Nothing else.
10
11use std::path::Path;
12
13/// URL-safe base64 without padding (JWT alphabet).
14fn b64url(bytes: &[u8]) -> String {
15    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
16    let mut out = String::new();
17    for chunk in bytes.chunks(3) {
18        let b = [
19            chunk[0],
20            *chunk.get(1).unwrap_or(&0),
21            *chunk.get(2).unwrap_or(&0),
22        ];
23        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
24        out.push(ALPHABET[(n >> 18) as usize & 63] as char);
25        out.push(ALPHABET[(n >> 12) as usize & 63] as char);
26        if chunk.len() > 1 {
27            out.push(ALPHABET[(n >> 6) as usize & 63] as char);
28        }
29        if chunk.len() > 2 {
30            out.push(ALPHABET[n as usize & 63] as char);
31        }
32    }
33    out
34}
35
36/// Mints a short-lived (9 min) App JWT for `app_id`, signing with the
37/// RS256 key at `pem` via the `openssl` binary.
38///
39/// # Errors
40///
41/// Human-readable failure of the temp I/O or openssl invocation.
42pub fn app_jwt(app_id: &str, pem: &Path) -> Result<String, String> {
43    let now = std::time::SystemTime::now()
44        .duration_since(std::time::UNIX_EPOCH)
45        .map_err(|e| e.to_string())?
46        .as_secs();
47    let header = b64url(br#"{"alg":"RS256","typ":"JWT"}"#);
48    let payload = b64url(
49        // iat backdated 60 s against clock skew, per GitHub's docs.
50        format!(
51            r#"{{"iat":{},"exp":{},"iss":"{app_id}"}}"#,
52            now - 60,
53            now + 540
54        )
55        .as_bytes(),
56    );
57    let signing_input = format!("{header}.{payload}");
58
59    let dir = std::env::temp_dir().join(format!("choir-jwt-{}", std::process::id()));
60    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
61    let input = dir.join("input");
62    let sig = dir.join("sig");
63    std::fs::write(&input, &signing_input).map_err(|e| e.to_string())?;
64    let out = std::process::Command::new("openssl")
65        .arg("dgst")
66        .arg("-sha256")
67        .arg("-sign")
68        .arg(pem)
69        .arg("-out")
70        .arg(&sig)
71        .arg(&input)
72        .output()
73        .map_err(|e| format!("spawn openssl: {e}"))?;
74    if !out.status.success() {
75        std::fs::remove_dir_all(&dir).ok();
76        return Err(format!(
77            "openssl sign failed: {}",
78            String::from_utf8_lossy(&out.stderr)
79        ));
80    }
81    let signature = std::fs::read(&sig).map_err(|e| e.to_string())?;
82    std::fs::remove_dir_all(&dir).ok();
83    Ok(format!("{signing_input}.{}", b64url(&signature)))
84}
85
86/// GitHub API call; returns (status, body).
87fn gh(method: &str, url: &str, auth: &str, body: Option<&str>) -> Result<(u16, String), String> {
88    let mut args: Vec<String> = vec![
89        "-s".into(),
90        "-w".into(),
91        "\n%{http_code}".into(),
92        "-X".into(),
93        method.into(),
94        "-H".into(),
95        format!("Authorization: Bearer {auth}"),
96        "-H".into(),
97        "Accept: application/vnd.github+json".into(),
98        "-H".into(),
99        "User-Agent: choir-bridge".into(),
100    ];
101    if let Some(b) = body {
102        args.push("-d".into());
103        args.push(b.into());
104    }
105    args.push(url.into());
106    let out = std::process::Command::new("curl")
107        .args(&args)
108        .output()
109        .map_err(|e| format!("spawn curl: {e}"))?;
110    let text = String::from_utf8_lossy(&out.stdout).into_owned();
111    match text.rsplit_once('\n') {
112        Some((b, code)) => Ok((code.trim().parse().unwrap_or(0), b.to_string())),
113        None => Ok((0, text)),
114    }
115}
116
117/// Exchanges the App JWT for an installation token scoped to whatever
118/// repos the installation covers.
119///
120/// # Errors
121///
122/// API failures with GitHub's response body included.
123pub fn installation_token(jwt: &str) -> Result<String, String> {
124    let (status, body) = gh("GET", "https://api.github.com/app/installations", jwt, None)?;
125    if status != 200 {
126        return Err(format!("list installations: {status}: {body}"));
127    }
128    let installs: serde_json::Value =
129        serde_json::from_str(&body).map_err(|e| format!("installations json: {e}"))?;
130    let id = installs
131        .as_array()
132        .and_then(|a| a.first())
133        .and_then(|i| i["id"].as_u64())
134        .ok_or("no installations found — is the App installed on a repo?")?;
135    let (status, body) = gh(
136        "POST",
137        &format!("https://api.github.com/app/installations/{id}/access_tokens"),
138        jwt,
139        None,
140    )?;
141    if status != 201 {
142        return Err(format!("mint token: {status}: {body}"));
143    }
144    let tok: serde_json::Value =
145        serde_json::from_str(&body).map_err(|e| format!("token json: {e}"))?;
146    tok["token"]
147        .as_str()
148        .map(String::from)
149        .ok_or_else(|| "token field missing".to_string())
150}
151
152/// Returns the raw installations JSON (debug aid: shows the permission
153/// set each installation has actually accepted).
154///
155/// # Errors
156///
157/// API failures with GitHub's response body included.
158pub fn installations_debug(jwt: &str) -> Result<String, String> {
159    let (status, body) = gh("GET", "https://api.github.com/app/installations", jwt, None)?;
160    if status != 200 {
161        return Err(format!("list installations: {status}: {body}"));
162    }
163    Ok(body)
164}
165
166/// Resolves the repo's default-branch HEAD sha via the API (works on
167/// private repos the installation covers).
168///
169/// # Errors
170///
171/// API failures with GitHub's response body included.
172pub fn head_sha(token: &str, repo: &str) -> Result<String, String> {
173    let (status, body) = gh(
174        "GET",
175        &format!("https://api.github.com/repos/{repo}/commits/HEAD"),
176        token,
177        None,
178    )?;
179    if status != 200 {
180        return Err(format!("resolve HEAD: {status}: {body}"));
181    }
182    let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
183    v["sha"]
184        .as_str()
185        .map(String::from)
186        .ok_or_else(|| "sha field missing".to_string())
187}
188
189/// Posts a commit status (`state`: success/failure/error/pending) on
190/// `owner/repo`@`sha` under `context`.
191///
192/// # Errors
193///
194/// API failures with GitHub's response body included.
195pub fn post_status(
196    token: &str,
197    repo: &str,
198    sha: &str,
199    context: &str,
200    state: &str,
201    description: &str,
202) -> Result<(), String> {
203    let body = serde_json::json!({
204        "state": state,
205        "context": context,
206        "description": description,
207    })
208    .to_string();
209    let (status, resp) = gh(
210        "POST",
211        &format!("https://api.github.com/repos/{repo}/statuses/{sha}"),
212        token,
213        Some(&body),
214    )?;
215    if status == 201 {
216        Ok(())
217    } else {
218        Err(format!("post status: {status}: {resp}"))
219    }
220}
221
222/// An open pull request as the queue sees it.
223///
224/// **This struct is a security boundary, not a convenience.** The queue
225/// reads pull requests from an upstream forge (untrusted input), holds a
226/// GitHub App private key (privileged credential), and with `--land`
227/// fast-forwards a base branch (external write) — all three legs of the
228/// prompt-injection lethal trifecta in one process. The Rule-of-Two
229/// mitigation is that no untrusted *text* may reach a decision path, and
230/// this type is where that is enforced: it carries a number and an oid,
231/// both structured, and deliberately carries **no title, body, branch
232/// name, or author**.
233///
234/// Adding a text field here is not a cosmetic change. A PR title in a
235/// status description is a channel from attacker-controlled text into
236/// the bot's own output, and a PR body reaching any conditional is the
237/// vulnerability itself. `bridge_trifecta.rs` fails if this type starts
238/// carrying attacker-controlled text.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct Pr {
241    /// PR number.
242    pub number: u64,
243    /// Head commit sha (what the verdict status is posted on).
244    pub head_sha: String,
245}
246
247/// Parses the response body of `GET /repos/{repo}/pulls` into queue
248/// entries, oldest PR first (train order = submission order).
249#[must_use]
250pub fn parse_prs(body: &str) -> Vec<Pr> {
251    let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
252    let mut prs: Vec<Pr> = v
253        .as_array()
254        .into_iter()
255        .flatten()
256        .filter_map(|p| {
257            Some(Pr {
258                number: p["number"].as_u64()?,
259                head_sha: p["head"]["sha"].as_str()?.to_string(),
260            })
261        })
262        .collect();
263    prs.sort_by_key(|p| p.number);
264    prs
265}
266
267/// Lists open PRs on `repo` (needs App permission Pull requests: read).
268///
269/// # Errors
270///
271/// API failures with GitHub's response body included.
272pub fn list_open_prs(token: &str, repo: &str) -> Result<Vec<Pr>, String> {
273    let (status, body) = gh(
274        "GET",
275        &format!("https://api.github.com/repos/{repo}/pulls?state=open&per_page=100"),
276        token,
277        None,
278    )?;
279    if status != 200 {
280        return Err(format!("list prs: {status}: {body}"));
281    }
282    Ok(parse_prs(&body))
283}
284
285/// Aggregate CI verdict for one commit.
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum Verdict {
288    /// No check runs reported yet (CI may not have started).
289    NoRuns,
290    /// At least one run still queued or in progress.
291    Pending,
292    /// All runs completed successfully (incl. neutral/skipped).
293    Success,
294    /// At least one run completed unsuccessfully.
295    Failure,
296}
297
298/// Parses the response body of `GET /commits/{sha}/check-runs` into a
299/// [`Verdict`]. Neutral and skipped conclusions count as success;
300/// anything else non-success (failure, cancelled, timed out) fails the
301/// train.
302///
303/// Reads **only** `status` and `conclusion`, each compared against a
304/// fixed set of literals. Check-run names, titles, summaries and output
305/// text are attacker-influenceable — a workflow is defined in the
306/// repository, so a pull request can propose one — and none of them
307/// reach this decision. See [`Pr`] for why that matters.
308#[must_use]
309pub fn parse_check_verdict(body: &str) -> Verdict {
310    let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
311    let runs: Vec<&serde_json::Value> = v["check_runs"].as_array().into_iter().flatten().collect();
312    if runs.is_empty() {
313        return Verdict::NoRuns;
314    }
315    let mut verdict = Verdict::Success;
316    for run in runs {
317        if run["status"].as_str() != Some("completed") {
318            return Verdict::Pending;
319        }
320        match run["conclusion"].as_str() {
321            Some("success" | "neutral" | "skipped") => {}
322            _ => verdict = Verdict::Failure,
323        }
324    }
325    verdict
326}
327
328/// Fetches the CI verdict for `sha` (needs App permission Checks:
329/// read; GitHub Actions reports through the Checks API).
330///
331/// # Errors
332///
333/// API failures with GitHub's response body included.
334pub fn check_verdict(token: &str, repo: &str, sha: &str) -> Result<Verdict, String> {
335    let (status, body) = gh(
336        "GET",
337        &format!("https://api.github.com/repos/{repo}/commits/{sha}/check-runs?per_page=100"),
338        token,
339        None,
340    )?;
341    if status != 200 {
342        return Err(format!("check runs: {status}: {body}"));
343    }
344    Ok(parse_check_verdict(&body))
345}
346
347/// Resolves the repo's default branch name.
348///
349/// # Errors
350///
351/// API failures with GitHub's response body included.
352pub fn default_branch(token: &str, repo: &str) -> Result<String, String> {
353    let (status, body) = gh(
354        "GET",
355        &format!("https://api.github.com/repos/{repo}"),
356        token,
357        None,
358    )?;
359    if status != 200 {
360        return Err(format!("repo info: {status}: {body}"));
361    }
362    let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
363    v["default_branch"]
364        .as_str()
365        .map(String::from)
366        .ok_or_else(|| "default_branch missing".to_string())
367}