20.8 KiB · text History 8389210

Plan: dependency security for repositories on the forge

Status: in progress. Step 0, A1, A2 and B are built (2026-10-22). 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) with permissions: (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_id are nullable already (on_delete: :nilify_all).
  • Renovate speaks Gitea/Forgejo, but through far more of the Gitea API than /api/v1 implements (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 / push workflows — the update PR needs its tests run — but a run started that way can’t, in turn, open issues or pull requests (its token’s issues/pull-requests are forced to none). One hop, no recursion. (GitHub instead triggers nothing from GITHUB_TOKEN events, which is why Dependabot PRs there need special-casing.)
  • At most 20 issues and 10 pull requests per run (config), counted in RateLimit by 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):

  1. osv-scanner scan source -r --format json . → the packages with a fix available, per lockfile.
  2. 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 fix for npm, go get <mod>@<ver> && go mod tidy. Ecosystems it doesn’t know are listed in the PR body, not touched.
  3. 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.
  4. 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.
  5. 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), into .osv-db beside the repositories (GitGud.Advisories.db_dir/0; GITGUD_OSV_DB_DIR overrides). No repository’s dependency list ever leaves the instance.
  • API. api.osv.dev/v1/querybatch per 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 beside gitgud, installing /usr/libexec/gitgud/osv-scanner. gitgud Suggests it. Bumping the scanner is a new gitgud-osv-scanner version, not a forge release.

  • Kubernetes (this instance): an init container from upstream’s own image, pinned by digest, copies the binary into a shared emptyDir the 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 repositories volume (.osv-db), 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 (:advisories queue, 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 archive of the matching paths) into a temp dir per repository, run osv-scanner scan source -r --offline-vulnerabilities --format json over 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 new security category.
  • 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-alerts behind a new security:read scope, 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)

  1. audit.yml for the forge itself. Done.
  2. A1 — issues / pull-requests job permissions, job tokens on the mobile API, the CI account, POST /issues and gg issue create. Done. A job closes, edits and labels only what it opened itself (the same author rule a person has); what it opens says which run on the issue and pull request pages; the CI account’s issues aren’t federated.
  3. A2 — the one-hop loop guard and per-run limits. Done. A job-token push already started no workflows (as on GitHub), so the guard is on pull requests: a run whose pull request a job opened gets issues / pull-requests: none (Permissions.one_hop/2). Caps are RateLimit policies job_issues (20) and job_pull_requests (10) per run, configurable. Left for B: a job’s force-push to its own PR’s branch starts no synchronize run today, so a refreshed update PR wouldn’t re-run its CI.
  4. B — scripts/dependency-update and the workflow, on this repository; then documented as a starter on /run-your-own. Built (scripts/dependency-update, Python 3, standard library; .forgejo/workflows/dependency-updates.yml, weekly and by hand). Hex and crates.io; Cargo goes to the lowest fixed version (--precise), Hex as far as mix.exs allows past it (mix has no --precise), and the pull request lists what came along with it. audit.yml runs on pull requests too, scanning only the lockfiles they change. A job refreshing its own update PR’s branch now starts that PR’s synchronize runs (still one hop). Not yet: a starter on /run-your-own, npm and Go, and a test run in the PR’s CI — this repository’s pull requests run audit.yml only, so whether a bump still builds is the reviewer’s to check.
  5. C1 — scan on push, security_alerts, the repository’s Security tab. Built (GitGud.Advisories, .Scanner, .Workers.Scan on the advisories queue, one at a time). The scanner is found, not shipped (above); without one the Security page and the org’s switch say so. A default-branch push queues a scan, which is skipped when the lockfiles’ blobs are what the last scan read; turning the switch on forces one. Only the lockfiles are exported (git archive), vendored ones (deps/, node_modules/, vendor/, target/) left out. One alert per vulnerability as osv-scanner groups its ids (an EEF id and its GHSA twin are one alert, the rest its aliases); gone from the lockfile → fixed, back → open again, dismissed stays dismissed. The gitgud-osv-scanner .deb, and dismissing from the page, are not yet. Measured on this repository’s mix.lock before the 2026-10 updates: 33 alerts, ~0.3 s with the Hex mirror warm.
  6. C2 — the OSV mirror, nightly rescans, notifications and digest.
  7. C3 — the org roll-up, dismissals in the audit log, the API and gg security, the “open an update PR” link.
  8. 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: neiam turns alerts on → the 21 findings of 2026-10-22 appear as alerts → the update workflow (B) opens PRs as gg-security → their CI, audit.yml included, goes green → merged → the alerts close as fixed on 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_request workflows, 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

  1. A forge-wide CI account (recommended), or one per organization, or author_id: nil with a job label?
  2. Should job-token PRs trigger pull_request workflows, with the one-hop guard (recommended), or nothing, as on GitHub?
  3. Allow pull-requests: write to approve or merge, ever? Recommended: never.
  4. Alerts’ visibility: writers and admins (recommended), or anyone who can read the repository, as for public advisories?
  5. OSV source default: mirror (recommended), API, or off until an operator turns it on?
  6. Ship osv-scanner in the release image and .deb (recommended), or require operators to install it?
  7. Notification policy: immediate for high/critical and a digest for the rest (recommended), or a per-repository setting from the start?
  8. Freshness updates (Dependabot’s version updates proper) in B’s first cut, or security-only first (recommended)?
  9. Android: generate Gradle lockfiles (dependencyLocking) so osv-scanner can read the app, or leave it uncovered?