Skip to content
395 linesCodeBlameRaw
1//! Workflow files: a token adds, changes or deletes files under
2//! `.g1t/workflows/` or `.github/workflows/` only with the
3//! `workflow_files:write` scope (a fine-grained token's Workflows
4//! permission). A workflow job's token never may. Without the gate, a token
5//! that can push code could write a workflow that runs with the
6//! repository's secrets and a stronger token than its own.
7//!
8//! A push is checked commit by commit: every commit it adds is compared
9//! with its first parent, looking only at the two workflow directories, so
10//! the check is complete however many other files a commit changes. A push
11//! too large to be read whole is refused for such a token, since what it
12//! holds cannot be checked. A signed-in person (no token) is never refused
13//! here, and neither is a token that has the scope: their roles and the
14//! repository's rules decide.
15
16use std::cell::Cell;
17
18use g1t_contracts::User;
19use g1t_contracts::scopes::{TokenAccess, WORKFLOW_DIRS, decide_workflow_files};
20use g1t_scan::pack::{ObjectKind, Pack, TreeItem, pack_start};
21use worker::{Response, Result};
22
23use crate::registry::store_key;
24use crate::rule_facts::{self, MAX_COMMITS};
25use crate::secret_scan::Objects;
26use crate::store::{GitRepo, GitStore};
27use crate::Repos;
28
29/// The token behind a push or an edit, when it is one this gate checks:
30/// any token without `workflow_files:write`, and every job's token.
31pub(crate) fn gated(actor: Option<&User>) -> Option<&TokenAccess> {
32 let token = actor?.token.as_deref()?;
33 decide_workflow_files(Some(token), [WORKFLOW_DIRS[0]]).map(|_| token)
34}
35
36/// The id of the tree at `dir` (such as `.github/workflows/`) under the
37/// tree `root`, if there is one.
38async fn subtree<R: GitRepo>(objects: &Objects<'_, R>, root: &str, dir: &str) -> Result<Option<String>> {
39 let mut id = root.to_owned();
40 for part in dir.trim_end_matches('/').split('/') {
41 let items = objects.tree(&id).await?;
42 match items.into_iter().find(|item| item.name == part && item.is_tree()) {
43 Some(item) => id = item.id,
44 None => return Ok(None),
45 }
46 }
47 Ok(Some(id))
48}
49
50/// The first entry that differs between two listings of one directory.
51fn first_difference(old: &[TreeItem], new: &[TreeItem]) -> Option<String> {
52 new.iter()
53 .find(|item| !old.iter().any(|before| before.name == item.name && before.id == item.id && before.mode == item.mode))
54 .or_else(|| old.iter().find(|item| !new.iter().any(|after| after.name == item.name)))
55 .map(|item| item.name.clone())
56}
57
58/// The first workflow file that differs between two root trees (`None`
59/// for a commit with no parent), as a path.
60pub(crate) async fn changed_between<R: GitRepo>(objects: &Objects<'_, R>, old_root: Option<&str>, new_root: &str) -> Result<Option<String>> {
61 for dir in WORKFLOW_DIRS {
62 let old = match old_root {
63 Some(root) => subtree(objects, root, dir).await?,
64 None => None,
65 };
66 let new = subtree(objects, new_root, dir).await?;
67 if old == new {
68 continue;
69 }
70 let old_items = match &old {
71 Some(id) => objects.tree(id).await?,
72 None => Vec::new(),
73 };
74 let new_items = match &new {
75 Some(id) => objects.tree(id).await?,
76 None => Vec::new(),
77 };
78 let name = first_difference(&old_items, &new_items).unwrap_or_default();
79 return Ok(Some(format!("{dir}{name}")));
80 }
81 Ok(None)
82}
83
84/// The first workflow file one of `ids` (commits the pack holds) changes
85/// against its first parent.
86pub(crate) async fn changed_in_commits<R: GitRepo>(pack: &Pack, repo: &R, ids: &[String]) -> Result<Option<String>> {
87 let objects = Objects { pack, repo, reads: Cell::new(0) };
88 for id in ids {
89 let Some((ObjectKind::Commit, data)) = pack.get(id) else {
90 continue;
91 };
92 let commit = rule_facts::read_commit(data);
93 let old_root = match commit.parents.first() {
94 Some(parent) => objects.commit_tree(parent).await?,
95 None => None,
96 };
97 if let Some(path) = changed_between(&objects, old_root.as_deref(), &commit.tree).await? {
98 return Ok(Some(path));
99 }
100 }
101 Ok(None)
102}
103
104/// Why a push is refused, as the reason git shows beside each ref and the
105/// lines it prints, when `token` may not change workflow files and the
106/// push (`body`, read `whole` or not) does or cannot be checked.
107pub(crate) async fn judge_push<R: GitRepo>(token: &TokenAccess, body: &[u8], whole: bool, git: &R) -> Result<Option<(&'static str, Vec<String>)>> {
108 let updates = crate::git_http::ref_updates(body);
109 if updates.iter().all(|(_, _, new)| new.is_none()) {
110 return Ok(None);
111 }
112 let too_large = |line: String| Ok(Some(("push too large to check for workflow files", vec![line, "Push in smaller parts, or with a token that has the workflow_files:write scope.".to_owned()])));
113 if !whole {
114 return too_large("This push is too large for g1t to check whether it changes workflow files, and this token may not change them.".to_owned());
115 }
116 let mut pack = match pack_start(body).map(|start| Pack::parse(&body[start..])) {
117 Some(Ok(pack)) => pack,
118 // Nothing but ref moves to commits the repository has.
119 None => return Ok(None),
120 Some(Err(problem)) => {
121 worker::console_error!("a push's pack could not be read for workflow files: {problem}");
122 return Ok(Some(("push could not be checked for workflow files", vec!["g1t could not read this push to check it for workflow files. Push again.".to_owned()])));
123 }
124 };
125 crate::secret_scan::supply_bases(&mut pack, git).await?;
126 for (_, _, new) in updates {
127 let Some(new) = new else { continue };
128 let Some(ids) = rule_facts::added(&pack, &new, MAX_COMMITS) else {
129 return too_large(format!("This push adds more than {MAX_COMMITS} commits to one ref, too many for g1t to check for workflow files, and this token may not change them."));
130 };
131 if let Some(path) = changed_in_commits(&pack, git, &ids).await? {
132 let reason = decide_workflow_files(Some(token), [path.as_str()])
133 .and_then(|decision| decision.reason)
134 .unwrap_or_else(|| format!("This access token cannot change the workflow file {path}."));
135 return Ok(Some((
136 "workflow files need the workflow_files:write scope",
137 vec![reason, "Push with a token that has the workflow_files:write scope, or make the change signed in on g1t.sh.".to_owned()],
138 )));
139 }
140 }
141 Ok(None)
142}
143
144impl<S: GitStore> Repos<S> {
145 /// For a push by a token this gate checks: the response declining it
146 /// when it changes a workflow file, or when it cannot be checked.
147 pub(crate) async fn workflow_gate(&self, repo: &g1t_contracts::repos::Repo, pusher: Option<&User>, body: &[u8], whole: bool) -> Result<Option<Response>> {
148 let Some(token) = gated(pusher) else {
149 return Ok(None);
150 };
151 let git = self.store.open(&store_key(repo)).await?;
152 match judge_push(token, body, whole, &git).await? {
153 Some((reason, lines)) => Ok(Some(crate::git_http::declined(body, reason, &lines)?)),
154 None => Ok(None),
155 }
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use std::collections::HashMap;
162 use std::future::Future;
163 use std::pin::pin;
164 use std::task::{Context, Poll, Waker};
165
166 use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry};
167 use g1t_contracts::scopes::{JobToken, Scope};
168 use g1t_scan::pack::{encode_tree, object_id, write_pack};
169
170 use super::*;
171 use crate::store::Scope as StoreScope;
172
173 fn run<F: Future>(future: F) -> F::Output {
174 match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) {
175 Poll::Ready(output) => output,
176 Poll::Pending => panic!("the fake store never waits"),
177 }
178 }
179
180 /// The repository before the push: one commit, with its trees.
181 #[derive(Default)]
182 struct Fake {
183 trees: HashMap<String, Vec<TreeEntry>>,
184 commits: HashMap<String, Commit>,
185 }
186
187 impl GitRepo for Fake {
188 async fn access(&self, _scope: StoreScope) -> Result<GitAccess> {
189 unimplemented!()
190 }
191 async fn branches(&self) -> Result<Vec<Branch>> {
192 Ok(Vec::new())
193 }
194 async fn log(&self, git_ref: &str, _limit: u32) -> Result<Vec<Commit>> {
195 Ok(self.commits.get(git_ref).cloned().into_iter().collect())
196 }
197 async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> {
198 Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone()))
199 }
200 async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
201 Ok(self.trees.get(tree_hash).cloned())
202 }
203 async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> {
204 Ok(None)
205 }
206 async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
207 Ok(None)
208 }
209 async fn fork(&self, _target_key: &str) -> Result<()> {
210 Ok(())
211 }
212 }
213
214 fn item(mode: &str, name: &str, id: &str) -> TreeItem {
215 TreeItem { mode: mode.into(), name: name.into(), id: id.into() }
216 }
217
218 /// Objects for one tree layout: a root with `dir/workflows/<file>`
219 /// holding `content`, and `README.md`.
220 struct Layout {
221 objects: Vec<(ObjectKind, Vec<u8>)>,
222 root: String,
223 }
224
225 fn layout(dir: &str, file: &str, content: &str, readme: &str) -> Layout {
226 let mut objects = Vec::new();
227 let mut add = |kind: ObjectKind, data: Vec<u8>| {
228 let id = object_id(kind, &data);
229 objects.push((kind, data));
230 id
231 };
232 let workflow = add(ObjectKind::Blob, content.as_bytes().to_vec());
233 let readme = add(ObjectKind::Blob, readme.as_bytes().to_vec());
234 let workflows = add(ObjectKind::Tree, encode_tree(&[item("100644", file, &workflow)]));
235 let parent = add(ObjectKind::Tree, encode_tree(&[item("40000", "workflows", &workflows)]));
236 let root = add(ObjectKind::Tree, encode_tree(&[item("40000", dir, &parent), item("100644", "README.md", &readme)]));
237 Layout { objects, root }
238 }
239
240 fn commit(tree: &str, parent: Option<&str>) -> (String, Vec<u8>) {
241 let parent = parent.map(|parent| format!("parent {parent}\n")).unwrap_or_default();
242 let data = format!("tree {tree}\n{parent}author A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\nchange\n").into_bytes();
243 (object_id(ObjectKind::Commit, &data), data)
244 }
245
246 /// The first workflow file a push of `after` on top of `before` changes.
247 fn check(before: &Layout, after: &Layout) -> Option<String> {
248 // The repository holds `before` and its commit; the pack, `after`.
249 let mut repo = Fake::default();
250 let base = Pack::parse(&write_pack(&before.objects)).unwrap();
251 for (kind, data) in &before.objects {
252 if *kind == ObjectKind::Tree {
253 let id = object_id(*kind, data);
254 let entries = base
255 .tree(&id)
256 .unwrap()
257 .into_iter()
258 .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } })
259 .collect();
260 repo.trees.insert(id, entries);
261 }
262 }
263 let (base_id, _) = commit(&before.root, None);
264 repo.commits.insert(
265 base_id.clone(),
266 Commit { hash: base_id.clone(), tree_hash: before.root.clone(), message: String::new(), author: Signature { name: String::new(), email: String::new() }, parents: Vec::new(), authored_at: String::new() },
267 );
268 let (tip, data) = commit(&after.root, Some(&base_id));
269 let mut objects = after.objects.clone();
270 objects.push((ObjectKind::Commit, data));
271 let pack = Pack::parse(&write_pack(&objects)).unwrap();
272 run(changed_in_commits(&pack, &repo, &[tip])).unwrap()
273 }
274
275 #[test]
276 fn a_push_that_changes_a_workflow_is_named_by_its_file() {
277 let before = layout(".github", "ci.yml", "on: push", "hello");
278 let changed = layout(".github", "ci.yml", "on: [push, pull_request]", "hello");
279 assert_eq!(check(&before, &changed).as_deref(), Some(".github/workflows/ci.yml"));
280 let added = layout(".github", "deploy.yml", "on: push", "hello");
281 assert!(check(&before, &added).unwrap().starts_with(".github/workflows/"));
282 // The g1t directory too, added where there was none.
283 let g1t = layout(".g1t", "ci.yml", "on: push", "hello");
284 assert_eq!(check(&before, &g1t).as_deref(), Some(".g1t/workflows/ci.yml"));
285 }
286
287 /// A receive-pack request moving `main` from `old` to `new`, with the pack.
288 fn receive_pack(old: &str, new: &str, pack: &[u8]) -> Vec<u8> {
289 let command = format!("{old} {new} refs/heads/main\0report-status side-band-64k\n");
290 let mut body = format!("{:04x}", command.len() + 4).into_bytes();
291 body.extend_from_slice(command.as_bytes());
292 body.extend_from_slice(b"0000");
293 body.extend_from_slice(pack);
294 body
295 }
296
297 /// The repository holding `before` at its commit, and a push of `after`
298 /// on top of it: the refusal, if any, for `pusher`'s token.
299 fn push(before: &Layout, after: &Layout, pusher: &User, whole: bool) -> Option<(&'static str, Vec<String>)> {
300 let mut repo = Fake::default();
301 let base = Pack::parse(&write_pack(&before.objects)).unwrap();
302 for (kind, data) in &before.objects {
303 if *kind == ObjectKind::Tree {
304 let id = object_id(*kind, data);
305 let entries = base
306 .tree(&id)
307 .unwrap()
308 .into_iter()
309 .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } })
310 .collect();
311 repo.trees.insert(id, entries);
312 }
313 }
314 let (base_id, _) = commit(&before.root, None);
315 repo.commits.insert(
316 base_id.clone(),
317 Commit { hash: base_id.clone(), tree_hash: before.root.clone(), message: String::new(), author: Signature { name: String::new(), email: String::new() }, parents: Vec::new(), authored_at: String::new() },
318 );
319 let (tip, data) = commit(&after.root, Some(&base_id));
320 let mut objects = after.objects.clone();
321 objects.push((ObjectKind::Commit, data));
322 let body = receive_pack(&base_id, &tip, &write_pack(&objects));
323 let token = gated(Some(pusher))?;
324 run(judge_push(token, &body, whole, &repo)).unwrap()
325 }
326
327 #[test]
328 fn a_git_push_of_a_workflow_change_is_declined_for_a_token_without_the_scope() {
329 let before = layout(".github", "ci.yml", "on: push", "hello");
330 let changed = layout(".github", "ci.yml", "on: [push, pull_request]\njobs: {}", "hello");
331 let (reason, lines) = push(&before, &changed, &token(&[Scope::CodeWrite]), true).expect("declined");
332 assert_eq!(reason, "workflow files need the workflow_files:write scope");
333 assert!(lines[0].contains(".github/workflows/ci.yml"), "{lines:?}");
334 assert!(lines[0].contains("workflow_files:write"), "{lines:?}");
335 // With the scope, or full access, it goes through.
336 assert!(push(&before, &changed, &token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]), true).is_none());
337 // A push that changes other files goes through without it.
338 let readme = layout(".github", "ci.yml", "on: push", "hello, world");
339 assert!(push(&before, &readme, &token(&[Scope::CodeWrite]), true).is_none());
340 // One too large to read whole cannot be checked, so it is declined.
341 let (reason, _) = push(&before, &readme, &token(&[Scope::CodeWrite]), false).expect("declined");
342 assert_eq!(reason, "push too large to check for workflow files");
343 }
344
345 #[test]
346 fn a_job_token_never_pushes_workflow_changes() {
347 let before = layout(".g1t", "ci.yml", "on: push", "hello");
348 let changed = layout(".g1t", "ci.yml", "on: workflow_dispatch", "hello");
349 let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]);
350 job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false });
351 let (_, lines) = push(&before, &changed, &job, true).expect("declined");
352 assert!(lines[0].contains("workflow job's token"), "{lines:?}");
353 }
354
355 #[test]
356 fn a_push_that_leaves_workflows_alone_passes() {
357 let before = layout(".github", "ci.yml", "on: push", "hello");
358 let readme = layout(".github", "ci.yml", "on: push", "hello, world");
359 assert_eq!(check(&before, &readme), None);
360 }
361
362 fn token(scopes: &[Scope]) -> User {
363 User {
364 token: Some(Box::new(TokenAccess {
365 token_id: "tok_1".into(),
366 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
367 ..TokenAccess::default()
368 })),
369 ..User::default()
370 }
371 }
372
373 #[test]
374 fn who_the_gate_checks() {
375 assert!(gated(None).is_none(), "nobody");
376 assert!(gated(Some(&User::default())).is_none(), "a signed-in person");
377 assert!(gated(Some(&token(&[Scope::CodeWrite]))).is_some(), "a token that may push code only");
378 assert!(gated(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]))).is_none());
379 let full = User { token: Some(Box::new(TokenAccess::full())), ..User::default() };
380 assert!(gated(Some(&full)).is_none(), "full access includes workflow files");
381 let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]);
382 job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false });
383 assert!(gated(Some(&job)).is_some(), "a job's token, whatever it holds");
384 }
385
386 #[test]
387 fn the_first_difference_names_added_changed_and_removed_entries() {
388 let a = item("100644", "a.yml", "1");
389 let b = item("100644", "b.yml", "2");
390 assert_eq!(first_difference(&[a.clone()], &[a.clone(), b.clone()]).as_deref(), Some("b.yml"));
391 assert_eq!(first_difference(&[a.clone(), b.clone()], &[a.clone()]).as_deref(), Some("b.yml"));
392 assert_eq!(first_difference(&[a.clone()], &[item("100644", "a.yml", "3")]).as_deref(), Some("a.yml"));
393 assert_eq!(first_difference(&[a.clone()], &[a]), None);
394 }
395}