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}