neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Job tokens: one hop, and a cap per run (A2)
d3fedc5 · Gabriel Morell · 2026-10-22 21:04
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