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