Plan: dependency security for repositories on the forge
Status: proposed (2026-10-22). Nothing here is built yet except step 0. Decisions are under “Decisions”; open questions 3 and 8 still have only 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 gg-security
(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 “gg-security
(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.
Who it’s on for
Off until an organization turns it on: a “Dependency alerts” switch
in the org’s settings, for its admins (org.advisories_enabled /
.advisories_disabled in the audit log). On, every repository the org
owns is scanned; a repository can’t opt out of its org’s choice. A
personal repository has the same switch in its own settings, for its
owner. The operator’s source: :off turns it off for the instance
whatever anyone chose. The mirror only downloads anything once at least
one org or repository has it on.
Where the scanner comes from
osv-scanner is a command, not a service, so the app runs it as a
subprocess wherever it’s installed. It looks for it at
GITGUD_OSV_SCANNER, then /usr/libexec/gitgud/osv-scanner, then
PATH; absent, the feature says so on the admin page and in each org’s
settings, and nothing else breaks. It is not baked into the app’s
image or the gitgud .deb, so the two move on separately:
-
.deb: a separate package,
gitgud-osv-scanner, built by CI from the pinned upstream release (checked against its SHA-256), published to the forge’s own apt repository besidegitgud, installing/usr/libexec/gitgud/osv-scanner.gitgudSuggests it. Bumping the scanner is a newgitgud-osv-scannerversion, not a forge release. -
Kubernetes (this instance): an init container from upstream’s own image, pinned by digest, copies the binary into a shared
emptyDirthe app container mounts:initContainers: - name: osv-scanner image: ghcr.io/google/osv-scanner:v2.6.0@sha256:<index digest> command: ["cp", "/osv-scanner", "/tools/osv-scanner"] volumeMounts: [{ name: tools, mountPath: /tools }] containers: - name: git-gud env: [{ name: GITGUD_OSV_SCANNER, value: /tools/osv-scanner }] volumeMounts: [{ name: tools, mountPath: /tools, readOnly: true }] volumes: [{ name: tools, emptyDir: {} }]A long-running sidecar would need a small RPC wrapper around a CLI for no gain; the init container is the sidecar pattern’s simpler cousin. The OSV dumps live on the existing data volume (
GITGUD_DATA_DIR/osv), so a restart doesn’t re-download them. -
Podman / Docker (
/run-your-own): the same: a named volume filled once from the upstream image, mounted read-only, the env variable pointing at it.
What the mirror costs (measured 2026-10-22, osv-scanner 2.6.0)
OSV’s per-ecosystem dumps, zipped, as osv-scanner keeps them:
| ecosystem | size | ecosystem | size | |
|---|---|---|---|---|
| npm | 209 MB | Maven | 10 MB | |
| PyPI | 35 MB | RubyGems | 5 MB | |
| Go | 12 MB | crates.io | 3.5 MB | |
| Packagist | 11 MB | NuGet | 2.6 MB | |
| Hex | 0.8 MB | Pub | <0.1 MB |
About 290 MB for all of them, three quarters of it npm (most of npm’s is malicious-package reports). The dumps change daily.
One scan, offline, loads the whole dump of each ecosystem it finds:
| repository | time | memory |
|---|---|---|
| this one (Hex + crates.io, 4.3 MB of dumps) | ~1 s | 80 MB |
| one npm lockfile (209 MB dump) | ~13 s | 600 MB |
So the cost is npm’s, and it’s per scan, not per package. Hence:
- Fetch only the ecosystems some repository on the instance has
(known from the last scans), with
If-Modified-Since. Without npm, a few MB a day; with it, ~210 MB a day (~6.5 GB a month in). - Nightly rescans run as one osv-scanner invocation over every repository’s exported lockfiles (a directory per repository), so each dump loads once a night, not once per repository.
- Per-push scans are debounced (a minute) and batched the same way, and
run one at a time (
:advisoriesqueue, concurrency 1), so an npm repository costs one 600 MB spike, not one per push in flight.
The API instead costs nothing locally and needs no storage, but sends each scan’s package names and versions to osv.dev, makes scanning depend on a third-party service being up, and for a private repository discloses its dependency inventory.
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 per repository, runosv-scanner scan source -r --offline-vulnerabilities --format jsonover the batch with a timeout and no network, and split the results by source path. Repositories’ own files never run as code (no--call-analysis). Oban queue:advisories, concurrency 1.
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: an org repository’s alerts, the org’s admins and members; a personal repository’s, its owner and collaborators. Not every reader of a public repository: an open alert is a map to what’s exploitable.
- 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.
Android
The app has a version catalog (gradle/libs.versions.toml) but no
lockfile, so neither audit.yml nor C can read it. Turning on Gradle’s
dependency locking (dependencyLocking { lockAllConfigurations() },
then ./gradlew dependencies --write-locks) commits a
gradle.lockfile osv-scanner reads. Nice to have; step 7.
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. - Android — Gradle dependency locking, so the app is scanned too.
Done when
mix precommit passes with no new warnings; one commit per step;
nothing pushed without asking (pushing deploys).
Decisions (2026-10-22)
-
OSV mirror by default (open question 5); the API stays an opt-in for an operator who’d rather not store the dumps.
-
Opt-in per organization (and per personal repository), off until turned on.
-
The scanner ships separately: its own
gitgud-osv-scanner.deb, and on Kubernetes an init container from upstream’s pinned image — not inside the app’s image or .deb (open question 6). -
Notifications as proposed (open question 7): immediately for high and critical, a daily digest for the rest.
-
Dogfood the whole lifecycle on the forge’s own findings before calling it done:
neiamturns alerts on → the 21 findings of 2026-10-22 appear as alerts → the update workflow (B) opens PRs asgg-security→ their CI,audit.ymlincluded, goes green → merged → the alerts close asfixedon the next scan. Until then those dependencies stay as they are, by choice. -
The CI account is
gg-security, one for the whole forge (open question 1). -
Job-token pull requests trigger
pull_requestworkflows, with the one-hop guard — the update PR’s own run is what shows the vulnerabilities closed (open question 2). -
Alerts are for admins and members — the org’s for an org repository, the owner and collaborators for a personal one (open question 4).
-
Android lockfiles: nice to have — step 7 (open question 9).
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?