Job tokens: one hop, and a cap per run (A2)

d3fedc5 · Gabriel Morell · 2026-10-22 21:04

24 files +281 -7
Message
{commit_body(@commit)}

Files changed

modified docs/plans/dependency-security.md
+9 −2
@@ -1,6 +1,6 @@
1 1 # Plan: dependency security for repositories on the forge
2 2
3 −Status: **in progress**. Step 0 and A1 are built (2026-10-22).
3 +Status: **in progress**. Step 0, A1 and A2 are built (2026-10-22).
4 4 Decisions are under "Decisions"; open questions 3 and 8 still have only
5 5 recommended defaults.
6 6
@@ -350,7 +350,14 @@ then `./gradlew dependencies --write-locks`) commits a
350 350 (the same author rule a person has); what it opens says which run on
351 351 the issue and pull request pages; the CI account's issues aren't
352 352 federated.
353 −2. **A2** — the one-hop loop guard and per-run limits.
353 +2. **A2** — the one-hop loop guard and per-run limits. **Done.** A
354 + job-token push already started no workflows (as on GitHub), so the
355 + guard is on pull requests: a run whose pull request a job opened
356 + gets `issues` / `pull-requests: none` (`Permissions.one_hop/2`). Caps
357 + are `RateLimit` policies `job_issues` (20) and `job_pull_requests`
358 + (10) per run, configurable. Left for B: a job's force-push to its
359 + own PR's branch starts no `synchronize` run today, so a refreshed
360 + update PR wouldn't re-run its CI.
354 361 3. **B** — `scripts/dependency-update` and the workflow, on this
355 362 repository; then documented as a starter on `/run-your-own`.
356 363 4. **C1** — osv-scanner in the image, scan on push, `security_alerts`,
modified lib/git_gud/rate_limit.ex
+7 −1
@@ -47,7 +47,11 @@ defmodule GitGud.RateLimit do
47 47 runner_fetch: {120, 60},
48 48 # A failed sign-in reaches the account's security log once per
49 49 # address per window, so a guessing run is one entry, not hundreds.
50 − login_failed_audit: {1, 15 * 60}
50 + login_failed_audit: {1, 15 * 60},
51 + # What one CI run may open with its job token, keyed by run id; a
52 + # day outlasts any run.
53 + job_issues: {20, 24 * 60 * 60},
54 + job_pull_requests: {10, 24 * 60 * 60}
51 55 }
52 56
53 57 @type policy ::
@@ -59,6 +63,8 @@ defmodule GitGud.RateLimit do
59 63 | :runner_register_ip
60 64 | :runner_fetch
61 65 | :login_failed_audit
66 + | :job_issues
67 + | :job_pull_requests
62 68
63 69 def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
64 70
modified lib/git_gud/workflows.ex
+15 −0
@@ -596,6 +596,21 @@ defmodule GitGud.Workflows do
596 596 end
597 597 end
598 598
599 + @doc """
600 + Was this run started by a pull request that a CI job opened? Such a
601 + run's own token can't open issues or pull requests
602 + (`Permissions.one_hop/2`).
603 + """
604 + def opened_by_job?(%WorkflowRun{pull_request_number: n, repository_id: rid})
605 + when is_integer(n) do
606 + Repo.exists?(
607 + from p in GitGud.PullRequests.PullRequest,
608 + where: p.repository_id == ^rid and p.number == ^n and not is_nil(p.workflow_run_id)
609 + )
610 + end
611 +
612 + def opened_by_job?(_run), do: false
613 +
599 614 @doc """
600 615 The workflows at the default branch's head that can be run by hand
601 616 (`on: workflow_dispatch`), as `[{path, doc}]` sorted by path. Empty
modified lib/git_gud/workflows/permissions.ex
+15 −0
@@ -93,6 +93,21 @@ defmodule GitGud.Workflows.Permissions do
93 93
94 94 defp all(level), do: Map.new(@scopes, &{&1, level})
95 95
96 + @doc """
97 + One hop, no more: a run started by a pull request a job opened (the
98 + update PR's own CI) keeps what its workflow grants, except `issues`
99 + and `pull-requests`, which are `none` — so a pipeline can't open
100 + something whose run opens something, and so on.
101 +
102 + `opened_by_job?` says whether the run's pull request was opened with a
103 + job token (`GitGud.Workflows.opened_by_job?/1`).
104 + """
105 + @spec one_hop(t(), boolean()) :: t()
106 + def one_hop(perms, false), do: perms
107 +
108 + def one_hop(perms, true),
109 + do: Map.merge(perms, %{"issues" => :none, "pull-requests" => :none})
110 +
96 111 @doc "As carried in the job token's `perm` claim: string values."
97 112 @spec to_claim(t()) :: %{String.t() => String.t()}
98 113 def to_claim(perms), do: Map.new(perms, fn {scope, level} -> {scope, Atom.to_string(level)} end)
modified lib/git_gud_web/controllers/api/mobile/helpers.ex
+24 −0
@@ -70,6 +70,30 @@ defmodule GitGudWeb.Api.Mobile.Helpers do
70 70 end
71 71 end
72 72
73 + @doc """
74 + `:ok`, or a halted 429 when a job's run has already opened as many
75 + issues or pull requests (`policy`, a `GitGud.RateLimit` policy keyed
76 + by run) as one run may. People aren't counted here.
77 + """
78 + def job_quota(conn, policy) do
79 + case conn.assigns[:api_job] do
80 + %{"rid" => rid} ->
81 + case GitGud.RateLimit.hit(policy, rid) do
82 + :ok ->
83 + :ok
84 +
85 + {:error, _retry_after} ->
86 + {limit, _} = GitGud.RateLimit.limits(policy)
87 +
88 + {:error,
89 + send_error(conn, 429, "this run has already opened #{limit}; that's the limit")}
90 + end
91 +
92 + _ ->
93 + :ok
94 + end
95 + end
96 +
73 97 @doc "The run a job token belongs to, as `workflow_run_id:` for what it creates."
74 98 def job_opts(conn) do
75 99 case conn.assigns[:api_job] do
modified lib/git_gud_web/controllers/api/mobile/issue_controller.ex
+2 −1
@@ -60,7 +60,8 @@ defmodule GitGudWeb.Api.Mobile.IssueController do
60 60 with {:ok, user} <- Auth.require_scope(conn, "issues:write"),
61 61 {:ok, repo} <- fetch_repo(conn, user, owner, name),
62 62 {:ok, title} <- require_title(conn, params),
63 − {:ok, labels} <- find_labels(conn, repo, params["labels"]) do
63 + {:ok, labels} <- find_labels(conn, repo, params["labels"]),
64 + :ok <- job_quota(conn, :job_issues) do
64 65 attrs = %{"title" => title, "body" => params["body"]}
65 66
66 67 case Issues.create_issue(repo, user, attrs, job_opts(conn)) do
modified lib/git_gud_web/controllers/api/mobile/pull_request_controller.ex
+2 −1
@@ -79,7 +79,8 @@ defmodule GitGudWeb.Api.Mobile.PullRequestController do
79 79 {:ok, repo} <- fetch_repo(conn, user, owner, name),
80 80 {:ok, source} <- head_repo(conn, user, repo, params["head_repo"]),
81 81 {:ok, head} <- require_branch(conn, source, params["head"], "head"),
82 − {:ok, base} <- require_branch(conn, repo, base_param(params, repo), "base") do
82 + {:ok, base} <- require_branch(conn, repo, base_param(params, repo), "base"),
83 + :ok <- job_quota(conn, :job_pull_requests) do
83 84 attrs = %{
84 85 "title" => params["title"],
85 86 "body" => params["body"],
modified lib/git_gud_web/controllers/runner_controller.ex
+6 −2
@@ -288,8 +288,12 @@ defmodule GitGudWeb.RunnerController do
288 288 defp grants(run, job, repo) do
289 289 {secrets, vars} = GitGud.Secrets.resolve_for(repo)
290 290
291 − {secrets, Map.merge(Context.builtin_vars(), vars),
292 − GitGud.Workflows.Permissions.effective(run.workflow_doc, job.definition)}
291 + perms =
292 + run.workflow_doc
293 + |> GitGud.Workflows.Permissions.effective(job.definition)
294 + |> GitGud.Workflows.Permissions.one_hop(GitGud.Workflows.opened_by_job?(run))
295 +
296 + {secrets, Map.merge(Context.builtin_vars(), vars), perms}
293 297 end
294 298
295 299 # Built-in CI variables the forge injects into every job
added test/git_gud_web/controllers/api/mobile/mobile_job_limits_test.exs
+156 −0
@@ -0,0 +1,156 @@
1 +defmodule GitGudWeb.Api.Mobile.MobileJobLimitsTest do
2 + @moduledoc """
3 + What keeps a pipeline that opens things from running away: one hop —
4 + the run a job-opened pull request starts can't open issues or pull
5 + requests itself — and a cap on how many one run may open.
6 + """
7 + # Changes the RateLimit config for the cap test.
8 + use GitGudWeb.ConnCase, async: false
9 +
10 + import GitGud.ForgeFixtures
11 + import GitGud.WorkflowGitHelpers
12 +
13 + alias GitGud.PullRequests
14 + alias GitGud.Repositories.Storage
15 + alias GitGud.Workflows
16 + alias GitGud.Workflows.Permissions
17 + alias GitGud.Workflows.RunnerJwt
18 + alias GitGud.Workflows.WorkflowJob
19 + alias GitGud.Workflows.WorkflowRun
20 +
21 + @base "/api/mobile/v1"
22 + @writes %{"issues" => "write", "pull-requests" => "write"}
23 +
24 + setup do
25 + {owner, repo} = repository_fixture()
26 + main = commit!(repo, %{"a" => "a\n"}, [])
27 + git!(repo.disk_path, ["update-ref", "refs/heads/main", main])
28 + fix = commit!(repo, %{"a" => "b\n"}, [main], "Bump a")
29 + git!(repo.disk_path, ["update-ref", "refs/heads/deps/security-hex", fix])
30 + {:ok, owner: owner, repo: repo, path: "#{Storage.owner_handle(owner)}/#{repo.name}"}
31 + end
32 +
33 + defp run(repo, attrs \\ %{}) do
34 + %WorkflowRun{repository_id: repo.id}
35 + |> WorkflowRun.create_changeset(
36 + Map.merge(
37 + %{
38 + number: System.unique_integer([:positive]),
39 + workflow_path: ".forgejo/workflows/ci.yml",
40 + trigger: "schedule",
41 + head_sha: :crypto.strong_rand_bytes(20)
42 + },
43 + attrs
44 + )
45 + )
46 + |> GitGud.Repo.insert!()
47 + end
48 +
49 + defp token(run, perms) do
50 + job =
51 + %WorkflowJob{workflow_run_id: run.id}
52 + |> WorkflowJob.create_changeset(%{job_id: "j", definition: %{}})
53 + |> Ecto.Changeset.change(status: "running", started_at: DateTime.utc_now(:second))
54 + |> GitGud.Repo.insert!()
55 +
56 + RunnerJwt.mint(job, run, perms)
57 + end
58 +
59 + defp as(token) do
60 + build_conn()
61 + |> put_req_header("authorization", "Bearer #{token}")
62 + |> put_req_header("accept", "application/json")
63 + end
64 +
65 + describe "one hop" do
66 + test "the run a job's pull request starts can't open anything", %{repo: repo, path: path} do
67 + opener = run(repo)
68 + perms = Permissions.effective(%{"permissions" => @writes}, nil)
69 +
70 + %{"pull" => %{"number" => n}} =
71 + as(token(opener, perms))
72 + |> post("#{@base}/repos/#{path}/pulls", %{
73 + "title" => "Security updates",
74 + "head" => "deps/security-hex",
75 + "base" => "main"
76 + })
77 + |> json_response(201)
78 +
79 + # That pull request's own CI, granted the same in its workflow.
80 + ci = run(repo, %{trigger: "pull_request", pull_request_number: n})
81 + assert Workflows.opened_by_job?(ci)
82 +
83 + limited = Permissions.one_hop(perms, Workflows.opened_by_job?(ci))
84 + assert {limited["issues"], limited["pull-requests"]} == {:none, :none}
85 + assert limited["contents"] == perms["contents"]
86 +
87 + assert as(token(ci, limited))
88 + |> post("#{@base}/repos/#{path}/issues", %{"title" => "and another"})
89 + |> json_response(403)
90 + end
91 +
92 + test "a person's pull request's run keeps what its workflow grants", %{
93 + repo: repo,
94 + owner: owner
95 + } do
96 + {:ok, pr} =
97 + PullRequests.create_pull_request(repo, owner, %{
98 + "title" => "by hand",
99 + "source_ref" => "deps/security-hex",
100 + "target_ref" => "main"
101 + })
102 +
103 + ci = run(repo, %{trigger: "pull_request", pull_request_number: pr.number})
104 + refute Workflows.opened_by_job?(ci)
105 + refute Workflows.opened_by_job?(run(repo))
106 +
107 + perms = Permissions.effective(%{"permissions" => @writes}, nil)
108 + assert Permissions.one_hop(perms, false) == perms
109 + end
110 + end
111 +
112 + describe "how many one run may open" do
113 + setup do
114 + before = Application.get_env(:git_gud, GitGud.RateLimit, [])
115 +
116 + Application.put_env(
117 + :git_gud,
118 + GitGud.RateLimit,
119 + Keyword.merge(before, job_issues: {2, 3600}, job_pull_requests: {1, 3600})
120 + )
121 +
122 + on_exit(fn -> Application.put_env(:git_gud, GitGud.RateLimit, before) end)
123 + end
124 +
125 + test "issues past the cap are refused, and the next run starts afresh", %{
126 + repo: repo,
127 + path: path
128 + } do
129 + perms = Permissions.effective(%{"permissions" => @writes}, nil)
130 + conn = as(token(run(repo), perms))
131 + open = fn conn -> post(conn, "#{@base}/repos/#{path}/issues", %{"title" => "x"}) end
132 +
133 + assert open.(conn).status == 201
134 + assert open.(conn).status == 201
135 + assert %{"message" => message} = open.(conn) |> json_response(429)
136 + assert message =~ "already opened 2"
137 +
138 + assert open.(as(token(run(repo), perms))).status == 201
139 + end
140 +
141 + test "pull requests too", %{repo: repo, path: path} do
142 + conn = as(token(run(repo), Permissions.effective(%{"permissions" => @writes}, nil)))
143 +
144 + open = fn ->
145 + post(conn, "#{@base}/repos/#{path}/pulls", %{
146 + "title" => "x",
147 + "head" => "deps/security-hex",
148 + "base" => "main"
149 + })
150 + end
151 +
152 + assert open.().status == 201
153 + assert open.().status == 429
154 + end
155 + end
156 +end
modified test/git_gud_web/controllers/runner_controller_test.exs
+45 −0
@@ -238,6 +238,51 @@ defmodule GitGudWeb.RunnerControllerTest do
238 238 assert context["forgejo_default_actions_url"] == context["gitea_default_actions_url"]
239 239 end
240 240
241 + test "FetchTask: the run a job-opened pull request started can't open more", %{
242 + conn: conn,
243 + runner_token: token
244 + } do
245 + # The pull request #7 a job opened, in the same repository.
246 + opened_by_job = fn repo ->
247 + opener =
248 + %GitGud.Workflows.WorkflowRun{repository_id: repo.id}
249 + |> GitGud.Workflows.WorkflowRun.create_changeset(%{
250 + number: 99,
251 + workflow_path: ".forgejo/workflows/deps.yml",
252 + trigger: "schedule",
253 + head_sha: :crypto.strong_rand_bytes(20)
254 + })
255 + |> GitGud.Repo.insert!()
256 +
257 + GitGud.Repo.insert!(%GitGud.PullRequests.PullRequest{
258 + repository_id: repo.id,
259 + source_repository_id: repo.id,
260 + number: 7,
261 + title: "Security updates",
262 + state: "open",
263 + source_ref: "deps/security-hex",
264 + target_ref: "main",
265 + head_sha: :crypto.strong_rand_bytes(20),
266 + base_sha: :crypto.strong_rand_bytes(20),
267 + workflow_run_id: opener.id
268 + })
269 + end
270 +
271 + writes = %{"permissions" => %{"issues" => "write", "pull-requests" => "write"}}
272 +
273 + {task, _run} =
274 + fetch_task(
275 + conn,
276 + token,
277 + %{trigger: "pull_request", pull_request_number: 7, workflow_doc: writes},
278 + opened_by_job
279 + )
280 +
281 + {:ok, claims} = GitGud.Workflows.RunnerJwt.verify(context(task)["token"])
282 + assert claims["perm"]["issues"] == "none"
283 + assert claims["perm"]["pull-requests"] == "none"
284 + end
285 +
241 286 test "FetchTask hands the job token over as secrets.GITHUB_TOKEN", %{
242 287 conn: conn,
243 288 runner_token: token

Parents: 16cf1ed