12.7 KiB · text History b05f3a1

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) 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 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 / 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 priv-adjacent storage (GITGUD_DATA_DIR/osv). 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.

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, run osv-scanner scan source -r --offline-vulnerabilities --format json there 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 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.

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.
  3. A2 — the one-hop loop guard and per-run limits.
  4. B — scripts/dependency-update and the workflow, on this repository; then documented as a starter on /run-your-own.
  5. C1 — osv-scanner in the image, scan on push, security_alerts, the repository’s Security tab.
  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.

Done when

mix precommit passes with no new warnings; one commit per step; nothing pushed without asking (pushing deploys).

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?