Plan: dependency security for repositories on the forge
Status: proposed (2026-10-22). Nothing here is built yet except step 0; the open questions at the end have recommended defaults.
Three things, in the order they depend on each other:
- A. Job tokens that can open issues and pull requests in their own
repository, under
permissions: issues: write/pull-requests: write. Small, and useful to any automation, not only this. - B. Dependency-update pull requests from CI — the Dependabot version-update half — as a workflow that scans, bumps, tests, pushes a branch and opens a PR with its job token. Possible once A lands, with no bot account and no stored personal token.
- C. Advisories built into the forge — the Dependabot alerts half: the forge reads each repository’s lockfiles, matches them against OSV, keeps an alert per finding, and shows and notifies them.
Why
A repository here learns about a vulnerable dependency only if someone runs a scanner by hand. The forge’s own lockfiles, scanned on 2026-10-22 with osv-scanner 2.6.0, had 21 affected packages, nine of them high (bandit, mint, plug, phoenix, hpax, mdex, hackney, protobuf, req) — none of which anything here would have said.
What exists to build on:
on: schedule(GitGud.Workflows.Schedules): five-field cron, read from the default branch, five-minute floor. A nightly scan is a workflow file.- Job tokens (
GitGud.Workflows.RunnerJwt) withpermissions:(GitGud.Workflows.Permissions):contents(read, or write = push, branch protection still applying),packages,actions. Every other GitHub scope is accepted and ignored — a job can push a branch but can’t open an issue or a pull request about it. - The mobile API (
/api/mobile/v1) can open a pull request (PullRequestController.create) and comment on issues, but takes personal access tokens only (GitGudWeb.Api.Mobile.Auth), and has no endpoint to open an issue. - Issues’ and pull requests’
author_idare nullable already (on_delete: :nilify_all). - Renovate speaks Gitea/Forgejo, but through far more of the Gitea API
than
/api/v1implements (it’s only what the runner needs); making Renovate work is a project of its own and not this plan.
0. A scheduled scan of the forge itself (done with this plan)
.forgejo/workflows/audit.yml: nightly at 06:17 UTC and by hand,
contents: read, fetches osv-scanner v2.6.0 pinned by SHA-256 and runs
osv-scanner scan source --recursive .. A finding fails the run. It
doesn’t cover Android: osv-scanner reads Gradle lockfiles and the app
has only gradle/libs.versions.toml (open question 9).
It will fail until the dependencies it found are updated.
A. Job tokens that open issues and pull requests
Scopes
GitGud.Workflows.Permissions gains issues and pull-requests as
scopes that mean something:
| scope | read | write |
|---|---|---|
issues |
list and read the job’s repository’s issues | open issues, comment, label, close/reopen |
pull-requests |
list and read its pull requests | open, edit, comment, label, close/reopen — not review or merge |
Neither is in the default a workflow without permissions: gets: an
existing workflow can’t start opening issues because this shipped. Only
the job’s own repository (the token’s pid); job-token grants
(JobTokenGrants) stay about packages and dispatch.
Reviews and merges are left out on purpose: a pipeline approving or
merging its own pull request would walk around branch protection’s
approval rules. (write-all therefore doesn’t grant them either.)
Authentication on the mobile API
Api.Mobile.Auth.call/2 tries a personal access token first, then
RunnerJwt.verify_active/1. A job token assigns :api_job (its claims)
and an :api_user of the forge’s CI identity (below). require_scope/2
maps the API’s scopes onto the token’s permissions:
| API scope | job permission |
|---|---|
issues:read / issues:write |
issues: read / write |
pulls:read / pulls:write |
pull-requests: read / write |
| anything else | refused (403) |
Helpers.fetch_repo/5 refuses (404) any repository but the token’s own
for a job. The endpoints a job can reach are exactly those behind those
four scopes, minus reviews (PullRequestController.review) and merges
(.merge), which refuse a job outright.
New endpoint: POST /api/mobile/v1/repos/:owner/:name/issues (title,
body, labels) → Issues.create_issue/3, for people and jobs alike;
gg issue create with it.
Who the issue or pull request is from
One forge-wide CI account, created by migration: handle gitgud-ci
(configurable), is_bot: true (new column), no password, no email
login, no SSH keys, can’t be logged into, never federated as a person.
Issues and PRs a job opens are authored by it, and their history
(GitGud.Events) and audit entries carry the job
(Audit.Actor.job/2: “run #12 / job deps”). The UI shows “gitgud-ci
(from run #12)” linking to the run.
Why one account rather than author_id: nil plus a label: every path
that renders, notifies about, mentions, filters by or federates an
author already handles a user; a nil author would need each of them
taught a second case. Why not the person who pushed: a scheduled run
has no one, and a person shouldn’t appear to have written what a
pipeline did.
Loops and limits
- A pull request or push made with a job token triggers
pull_request/pushworkflows — the update PR needs its tests run — but a run started that way can’t, in turn, open issues or pull requests (its token’sissues/pull-requestsare forced tonone). One hop, no recursion. (GitHub instead triggers nothing fromGITHUB_TOKENevents, which is why Dependabot PRs there need special-casing.) - At most 20 issues and 10 pull requests per run (config), counted in
RateLimitby run id; past that, 429.
Tests
Scope mapping both ways; own-repository only; review and merge refused;
default permissions grants neither; the loop guard; the per-run
limits; the CI account can’t log in or be federated; POST /issues
for a user and a job.
B. Dependency-update pull requests (version updates)
A workflow, and a script it runs, shipped in this repository as a starter for any repository here and used first on the forge itself.
name: dependency-updates
on:
schedule: [{ cron: "23 5 * * 1" }] # weekly
workflow_dispatch:
permissions:
contents: write # push the branch
pull-requests: write # open or refresh the PR (part A)
jobs:
update:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: <fetch osv-scanner, pinned, as in audit.yml>
- run: ./scripts/dependency-update # below
scripts/dependency-update (POSIX sh or a small Elixir escript):
osv-scanner scan source -r --format json .→ the packages with a fix available, per lockfile.- Per ecosystem, bump each to the lowest fixed version:
mix deps.update <pkg>(Hex, then check the lock reached the fixed version),cargo update -p <crate> --precise <ver>,npm install <pkg>@<ver>/npm audit fixfor npm,go get <mod>@<ver> && go mod tidy. Ecosystems it doesn’t know are listed in the PR body, not touched. - Run the repository’s own test command if it declares one (input
test-command), so a bump that breaks the build is marked as such. - Push
deps/security-<ecosystem>(force-with-lease: the branch is the bot’s) and open a PR, or update the open one for that branch — one PR per ecosystem, refreshed in place, never a pile. The body: a table of package, from, to, advisories (OSV links), and what was left alone and why. - Nothing to fix: close the open one, if any, with a comment.
Not in this part: bumps for freshness rather than security (Dependabot’s
version updates proper). Same machinery with mix hex.outdated /
cargo outdated instead of osv-scanner; a later input
(mode: security | all).
C. Advisories built into the forge (alerts)
Data
OSV (https://osv.dev), which aggregates GitHub’s advisories, RustSec, the EEF’s for Hex, PyPA, Go, npm and more. Two ways to ask it:
- Mirror (recommended). A daily job downloads the ecosystem dumps
osv-scanner’s offline mode uses (
--download-offline-databases), intopriv-adjacent storage (GITGUD_DATA_DIR/osv). No repository’s dependency list ever leaves the instance. - API.
api.osv.dev/v1/querybatchper scan: no storage, but every repository’s dependencies go to Google on every push. Opt-in only.
Config: config :git_gud, GitGud.Advisories, source: :mirror | :api | :off (default :mirror), plus the ecosystems to fetch.
Scanning
Shell out to osv-scanner rather than parse lockfiles in Elixir: it already reads mix.lock, Cargo.lock, package-lock/yarn/pnpm, go.sum, poetry/uv/Pipfile locks, Gemfile.lock, composer.lock, gradle and maven locks, and its version-range matching per ecosystem is the hard part. Pinned and checksummed in the Dockerfile and the .deb, like any other runtime dependency; absent, the feature reports itself off.
- When: after a push moves the default branch and touched a file a scanner reads (from the push’s changed paths), and nightly for every repository after the mirror refreshes — new advisories arrive without anyone pushing.
- How: export only the lockfiles at the head commit (
git archiveof the matching paths) into a temp dir, runosv-scanner scan source -r --offline-vulnerabilities --format jsonthere with a timeout and no network. Repositories’ own files never run as code (no--call-analysis). Oban queue:advisories, concurrency 2.
Alerts
Table security_alerts: repository_id, advisory_id (OSV id, with
its GHSA/CVE aliases), ecosystem, package, version,
fixed_versions, severity (CVSS), manifest_path, state
(open | fixed | dismissed), dismissed_reason, dismissed_by_id,
first_seen_at, fixed_at. A rescan opens what’s new, marks fixed
what’s gone, leaves dismissed alone (unless the version changes).
- Who sees them: a repository’s writers and admins, and the org’s admins — an open alert is a map to what’s exploitable, so not every reader of a public repository (GitHub draws the same line).
- Where: a “Security” tab on the repository
(
/r/:owner/:name/security[/:state], path segments), a roll-up at/orgs/:handle/security, and the dashboard’s attention panel. - Telling people: a new high or critical alert notifies the repository’s admins (in-app, and email where they’ve allowed it); the rest go in a daily digest.
- Audit log: dismissals and reopenings
(
security_alert.dismissed {advisory, reason}/.reopened), in a newsecuritycategory. - Fixing: an alert’s page links the update workflow (B) — “Open an update PR” dispatches it — when the repository has one.
- API and CLI:
GET /api/mobile/v1/repos/:owner/:name/security-alertsbehind a newsecurity:readscope,gg security.
Phasing (each ships on its own)
audit.ymlfor the forge itself. Done.- A1 —
issues/pull-requestsjob permissions, job tokens on the mobile API, the CI account,POST /issuesandgg issue create. - A2 — the one-hop loop guard and per-run limits.
- B —
scripts/dependency-updateand the workflow, on this repository; then documented as a starter on/run-your-own. - C1 — osv-scanner in the image, scan on push,
security_alerts, the repository’s Security tab. - C2 — the OSV mirror, nightly rescans, notifications and digest.
- C3 — the org roll-up, dismissals in the audit log, the API and
gg security, the “open an update PR” link.
Done when
mix precommit passes with no new warnings; one commit per step;
nothing pushed without asking (pushing deploys).
Open questions
- A forge-wide CI account (recommended), or one per organization, or
author_id: nilwith a job label? - Should job-token PRs trigger
pull_requestworkflows, with the one-hop guard (recommended), or nothing, as on GitHub? - Allow
pull-requests: writeto approve or merge, ever? Recommended: never. - Alerts’ visibility: writers and admins (recommended), or anyone who can read the repository, as for public advisories?
- OSV source default: mirror (recommended), API, or off until an operator turns it on?
- Ship osv-scanner in the release image and .deb (recommended), or require operators to install it?
- Notification policy: immediate for high/critical and a digest for the rest (recommended), or a per-repository setting from the start?
- Freshness updates (Dependabot’s version updates proper) in B’s first cut, or security-only first (recommended)?
- Android: generate Gradle lockfiles (
dependencyLocking) so osv-scanner can read the app, or leave it uncovered?