Skip to main content

vgi_forge_github/
resign.rs

1//! The GitHub half of the Dependabot re-sign (§9, "Dependabot re-sign bot").
2//!
3//! verify-trust exempts a `web-flow`-signed commit only when it is a clean
4//! merge, so Dependabot's single-parent commits fail the check: nothing in
5//! a commit binds it to Dependabot (any writer can have GitHub write and
6//! sign a commit with any `author` through the Contents API). The bridge
7//! re-signs them with its own DID instead — but only on **provenance from
8//! signed `push` webhooks**, never on authorship: every push to the branch
9//! since its creation must have come from Dependabot, or be the bridge's own
10//! re-sign.
11//!
12//! This module is what the bridge needs from GitHub for that:
13//!
14//! - [`GitHubForge::parse_push`]: verify a delivery (signature first) and,
15//!   if it is a `push`, the fields the provenance ledger records — who
16//!   pushed (login and numeric id), the branch, `before` / `after`, and the
17//!   `created` / `deleted` / `forced` flags. The sender is GitHub's
18//!   statement of the authenticated actor; nothing in the pushed commits is
19//!   read.
20//! - [`GitHubForge::contents_write_token`]: a token that can push to one
21//!   repository, for the one force-push of the re-signed commits.
22//!
23//! Who opened the pull request, and where its head is, come from
24//! [`GitHubForge::pull_request`].
25
26use http::HeaderMap;
27use serde_json::Value;
28use vgi_forge::{ForgeError, Resource, Result};
29
30use crate::forge::GitHubForge;
31use crate::secret::Secret;
32use crate::webhook;
33
34/// Pushing the re-signed commits: Contents (write) on the one repository.
35const PERMS_PUSH: &[(&str, &str)] = &[("contents", "write"), ("metadata", "read")];
36
37/// The all-zero object id GitHub reports as `before` for a created branch
38/// and as `after` for a deleted one.
39pub const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
40
41/// One verified `push` delivery.
42#[derive(Debug, Clone, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct PushEvent {
45    /// The repository pushed to.
46    pub repo: Resource,
47    /// Its forge id.
48    pub repo_id: u64,
49    /// The full ref (`refs/heads/dependabot/cargo/foo-1.2.3`).
50    pub git_ref: String,
51    /// The ref's value before the push ([`ZERO_SHA`] when it was created).
52    pub before: String,
53    /// Its value after ([`ZERO_SHA`] when it was deleted).
54    pub after: String,
55    /// The push created the ref.
56    pub created: bool,
57    /// The push deleted the ref.
58    pub deleted: bool,
59    /// The push was not a fast-forward.
60    pub forced: bool,
61    /// The login of the account GitHub says pushed.
62    pub sender_login: String,
63    /// That account's numeric id.
64    pub sender_id: u64,
65    /// GitHub's delivery id.
66    pub delivery_id: Option<String>,
67    /// `repository.pushed_at`: when GitHub says the repository was last
68    /// pushed to, as it built this delivery (Unix seconds). The signature
69    /// covers it, so an old delivery replayed keeps its old time.
70    pub pushed_at: Option<i64>,
71}
72
73impl PushEvent {
74    /// The branch, if the ref is one (`refs/heads/<branch>`).
75    pub fn branch(&self) -> Option<&str> {
76        self.git_ref.strip_prefix("refs/heads/")
77    }
78}
79
80/// A 40- or 64-hex object id, or the all-zero id.
81fn object_id(v: &Value, key: &str) -> Result<String> {
82    let s = v
83        .get(key)
84        .and_then(Value::as_str)
85        .ok_or_else(|| ForgeError::Webhook(format!("push is missing `{key}`")))?;
86    crate::checks::check_sha(s)
87        .map_err(|_| ForgeError::Webhook(format!("push `{key}` is not an object id")))?;
88    Ok(s.to_string())
89}
90
91/// `repository.pushed_at`: Unix seconds in `push` payloads.
92fn pushed_at(p: &Value) -> Option<i64> {
93    p.pointer("/repository/pushed_at").and_then(Value::as_i64)
94}
95
96impl GitHubForge {
97    /// Verify a webhook and, if it is a `push`, read it. `Ok(None)` for a
98    /// verified delivery of any other event; `Err` for one that failed
99    /// verification or is malformed, which must not be acted on.
100    pub fn parse_push(&self, headers: &HeaderMap, body: &[u8]) -> Result<Option<PushEvent>> {
101        webhook::verify_signature(self.webhook_secret(), headers, body)?;
102        let event = headers
103            .get("x-github-event")
104            .and_then(|v| v.to_str().ok())
105            .ok_or_else(|| ForgeError::Webhook("missing X-GitHub-Event".into()))?;
106        if event != "push" {
107            return Ok(None);
108        }
109        let delivery_id = headers
110            .get("x-github-delivery")
111            .and_then(|v| v.to_str().ok())
112            .map(str::to_string);
113        let p: Value = serde_json::from_slice(body)
114            .map_err(|e| ForgeError::Webhook(format!("body is not JSON: {e}")))?;
115        let full_name = p
116            .pointer("/repository/full_name")
117            .and_then(Value::as_str)
118            .ok_or_else(|| ForgeError::Webhook("push is missing `repository.full_name`".into()))?;
119        let repo = Resource::parse_owner_repo(&format!("{}/{full_name}", self.config().host))?;
120        let repo_id = p
121            .pointer("/repository/id")
122            .and_then(Value::as_u64)
123            .ok_or_else(|| ForgeError::Webhook("push is missing `repository.id`".into()))?;
124        let git_ref = p
125            .get("ref")
126            .and_then(Value::as_str)
127            .filter(|r| !r.is_empty())
128            .ok_or_else(|| ForgeError::Webhook("push is missing `ref`".into()))?
129            .to_string();
130        let flag = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
131        let sender_login = p
132            .pointer("/sender/login")
133            .and_then(Value::as_str)
134            .filter(|l| !l.is_empty())
135            .ok_or_else(|| ForgeError::Webhook("push is missing `sender.login`".into()))?
136            .to_string();
137        let sender_id = p
138            .pointer("/sender/id")
139            .and_then(Value::as_u64)
140            .ok_or_else(|| ForgeError::Webhook("push is missing `sender.id`".into()))?;
141        Ok(Some(PushEvent {
142            repo,
143            repo_id,
144            git_ref,
145            before: object_id(&p, "before")?,
146            after: object_id(&p, "after")?,
147            created: flag("created"),
148            deleted: flag("deleted"),
149            forced: flag("forced"),
150            sender_login,
151            sender_id,
152            delivery_id,
153            pushed_at: pushed_at(&p),
154        }))
155    }
156
157    /// A token that can push to `repo` and nothing else, for one push of
158    /// re-signed commits. The caller holds it for that push only.
159    pub async fn contents_write_token(&self, repo: &Resource) -> Result<Secret> {
160        Ok(self.repo_token_for(repo, PERMS_PUSH).await?.0)
161    }
162}