neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
CI: a nightly osv-scanner audit of the forge's lockfiles; plan for dependency security
b05f3a1 · Gabriel Morell · 2026-10-22 19:37
Message
{commit_body(@commit)}
Files changed
added
.forgejo/workflows/audit.yml
+42
−0
@@ -0,0 +1,42 @@
| 1 | +name: audit | |
| 2 | +# Known vulnerabilities in what the forge is built from: every lockfile | |
| 3 | +# in the repository (mix.lock, the CLI's and the NIFs' Cargo.lock) | |
| 4 | +# checked against the OSV database (https://osv.dev). A finding fails | |
| 5 | +# the run, which is how it gets noticed — the CI page, the panel widget, | |
| 6 | +# `gg ci list`. Nightly, since advisories land without anyone pushing, | |
| 7 | +# and by hand from the Actions page. | |
| 8 | +# | |
| 9 | +# The Android app isn't covered: osv-scanner reads Gradle lockfiles, | |
| 10 | +# and the app has a version catalog (gradle/libs.versions.toml) only. | |
| 11 | +on: | |
| 12 | + schedule: | |
| 13 | + - cron: "17 6 * * *" | |
| 14 | + workflow_dispatch: | |
| 15 | + | |
| 16 | +permissions: | |
| 17 | + contents: read | |
| 18 | + | |
| 19 | +jobs: | |
| 20 | + osv: | |
| 21 | + runs-on: self-hosted | |
| 22 | + steps: | |
| 23 | + - uses: actions/checkout@v4 | |
| 24 | + | |
| 25 | + # Pinned, and checked against the release's published checksum | |
| 26 | + # before it runs: a scanner is a strange thing to take on trust. | |
| 27 | + # To move it on, change both together, from | |
| 28 | + # https://github.com/google/osv-scanner/releases (…_SHA256SUMS). | |
| 29 | + - name: Fetch osv-scanner | |
| 30 | + env: | |
| 31 | + OSV_VERSION: v2.6.0 | |
| 32 | + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108 | |
| 33 | + run: | | |
| 34 | + curl -sSfL -o /tmp/osv-scanner \ | |
| 35 | + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64" | |
| 36 | + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c - | |
| 37 | + chmod +x /tmp/osv-scanner | |
| 38 | + | |
| 39 | + # Exit 1 when anything is vulnerable, so the run fails; the table | |
| 40 | + # names each package, its version, and the version that fixes it. | |
| 41 | + - name: Scan the lockfiles | |
| 42 | + run: /tmp/osv-scanner scan source --recursive . |
added
docs/plans/dependency-security.md
+282
−0
@@ -0,0 +1,282 @@
| 1 | +# Plan: dependency security for repositories on the forge | |
| 2 | + | |
| 3 | +Status: **proposed** (2026-10-22). Nothing here is built yet except | |
| 4 | +step 0; the open questions at the end have recommended defaults. | |
| 5 | + | |
| 6 | +Three things, in the order they depend on each other: | |
| 7 | + | |
| 8 | +- **A. Job tokens that can open issues and pull requests** in their own | |
| 9 | + repository, under `permissions: issues: write` / `pull-requests: | |
| 10 | + write`. Small, and useful to any automation, not only this. | |
| 11 | +- **B. Dependency-update pull requests from CI** — the Dependabot | |
| 12 | + version-update half — as a workflow that scans, bumps, tests, pushes a | |
| 13 | + branch and opens a PR with its job token. Possible once A lands, with | |
| 14 | + no bot account and no stored personal token. | |
| 15 | +- **C. Advisories built into the forge** — the Dependabot alerts half: | |
| 16 | + the forge reads each repository's lockfiles, matches them against | |
| 17 | + OSV, keeps an alert per finding, and shows and notifies them. | |
| 18 | + | |
| 19 | +## Why | |
| 20 | + | |
| 21 | +A repository here learns about a vulnerable dependency only if someone | |
| 22 | +runs a scanner by hand. The forge's own lockfiles, scanned on | |
| 23 | +2026-10-22 with osv-scanner 2.6.0, had 21 affected packages, nine of | |
| 24 | +them high (bandit, mint, plug, phoenix, hpax, mdex, hackney, protobuf, | |
| 25 | +req) — none of which anything here would have said. | |
| 26 | + | |
| 27 | +What exists to build on: | |
| 28 | + | |
| 29 | +- `on: schedule` (`GitGud.Workflows.Schedules`): five-field cron, read | |
| 30 | + from the default branch, five-minute floor. A nightly scan is a | |
| 31 | + workflow file. | |
| 32 | +- Job tokens (`GitGud.Workflows.RunnerJwt`) with `permissions:` | |
| 33 | + (`GitGud.Workflows.Permissions`): `contents` (read, or write = push, | |
| 34 | + branch protection still applying), `packages`, `actions`. Every other | |
| 35 | + GitHub scope is accepted and ignored — a job can push a branch but | |
| 36 | + can't open an issue or a pull request about it. | |
| 37 | +- The mobile API (`/api/mobile/v1`) can open a pull request | |
| 38 | + (`PullRequestController.create`) and comment on issues, but takes | |
| 39 | + personal access tokens only (`GitGudWeb.Api.Mobile.Auth`), and has no | |
| 40 | + endpoint to open an issue. | |
| 41 | +- Issues' and pull requests' `author_id` are nullable already | |
| 42 | + (`on_delete: :nilify_all`). | |
| 43 | +- Renovate speaks Gitea/Forgejo, but through far more of the Gitea API | |
| 44 | + than `/api/v1` implements (it's only what the runner needs); making | |
| 45 | + Renovate work is a project of its own and not this plan. | |
| 46 | + | |
| 47 | +## 0. A scheduled scan of the forge itself (done with this plan) | |
| 48 | + | |
| 49 | +`.forgejo/workflows/audit.yml`: nightly at 06:17 UTC and by hand, | |
| 50 | +`contents: read`, fetches osv-scanner v2.6.0 pinned by SHA-256 and runs | |
| 51 | +`osv-scanner scan source --recursive .`. A finding fails the run. It | |
| 52 | +doesn't cover Android: osv-scanner reads Gradle lockfiles and the app | |
| 53 | +has only `gradle/libs.versions.toml` (open question 9). | |
| 54 | + | |
| 55 | +It will fail until the dependencies it found are updated. | |
| 56 | + | |
| 57 | +## A. Job tokens that open issues and pull requests | |
| 58 | + | |
| 59 | +### Scopes | |
| 60 | + | |
| 61 | +`GitGud.Workflows.Permissions` gains `issues` and `pull-requests` as | |
| 62 | +scopes that mean something: | |
| 63 | + | |
| 64 | +| scope | read | write | | |
| 65 | +|---|---|---| | |
| 66 | +| `issues` | list and read the job's repository's issues | open issues, comment, label, close/reopen | | |
| 67 | +| `pull-requests` | list and read its pull requests | open, edit, comment, label, close/reopen — **not** review or merge | | |
| 68 | + | |
| 69 | +Neither is in the default a workflow without `permissions:` gets: an | |
| 70 | +existing workflow can't start opening issues because this shipped. Only | |
| 71 | +the job's own repository (the token's `pid`); job-token grants | |
| 72 | +(`JobTokenGrants`) stay about packages and dispatch. | |
| 73 | + | |
| 74 | +Reviews and merges are left out on purpose: a pipeline approving or | |
| 75 | +merging its own pull request would walk around branch protection's | |
| 76 | +approval rules. (`write-all` therefore doesn't grant them either.) | |
| 77 | + | |
| 78 | +### Authentication on the mobile API | |
| 79 | + | |
| 80 | +`Api.Mobile.Auth.call/2` tries a personal access token first, then | |
| 81 | +`RunnerJwt.verify_active/1`. A job token assigns `:api_job` (its claims) | |
| 82 | +and an `:api_user` of the forge's CI identity (below). `require_scope/2` | |
| 83 | +maps the API's scopes onto the token's permissions: | |
| 84 | + | |
| 85 | +| API scope | job permission | | |
| 86 | +|---|---| | |
| 87 | +| `issues:read` / `issues:write` | `issues: read` / `write` | | |
| 88 | +| `pulls:read` / `pulls:write` | `pull-requests: read` / `write` | | |
| 89 | +| anything else | refused (403) | | |
| 90 | + | |
| 91 | +`Helpers.fetch_repo/5` refuses (404) any repository but the token's own | |
| 92 | +for a job. The endpoints a job can reach are exactly those behind those | |
| 93 | +four scopes, minus reviews (`PullRequestController.review`) and merges | |
| 94 | +(`.merge`), which refuse a job outright. | |
| 95 | + | |
| 96 | +New endpoint: `POST /api/mobile/v1/repos/:owner/:name/issues` (`title`, | |
| 97 | +`body`, `labels`) → `Issues.create_issue/3`, for people and jobs alike; | |
| 98 | +`gg issue create` with it. | |
| 99 | + | |
| 100 | +### Who the issue or pull request is from | |
| 101 | + | |
| 102 | +One forge-wide CI account, created by migration: handle `gitgud-ci` | |
| 103 | +(configurable), `is_bot: true` (new column), no password, no email | |
| 104 | +login, no SSH keys, can't be logged into, never federated as a person. | |
| 105 | +Issues and PRs a job opens are authored by it, and their history | |
| 106 | +(`GitGud.Events`) and audit entries carry the job | |
| 107 | +(`Audit.Actor.job/2`: "run #12 / job deps"). The UI shows "gitgud-ci | |
| 108 | +(from run #12)" linking to the run. | |
| 109 | + | |
| 110 | +Why one account rather than `author_id: nil` plus a label: every path | |
| 111 | +that renders, notifies about, mentions, filters by or federates an | |
| 112 | +author already handles a user; a nil author would need each of them | |
| 113 | +taught a second case. Why not the person who pushed: a scheduled run | |
| 114 | +has no one, and a person shouldn't appear to have written what a | |
| 115 | +pipeline did. | |
| 116 | + | |
| 117 | +### Loops and limits | |
| 118 | + | |
| 119 | +- A pull request or push made with a job token triggers `pull_request` / | |
| 120 | + `push` workflows — the update PR needs its tests run — but a run | |
| 121 | + started that way can't, in turn, open issues or pull requests (its | |
| 122 | + token's `issues`/`pull-requests` are forced to `none`). One hop, no | |
| 123 | + recursion. (GitHub instead triggers nothing from `GITHUB_TOKEN` | |
| 124 | + events, which is why Dependabot PRs there need special-casing.) | |
| 125 | +- At most 20 issues and 10 pull requests per run (config), counted in | |
| 126 | + `RateLimit` by run id; past that, 429. | |
| 127 | + | |
| 128 | +### Tests | |
| 129 | + | |
| 130 | +Scope mapping both ways; own-repository only; review and merge refused; | |
| 131 | +default `permissions` grants neither; the loop guard; the per-run | |
| 132 | +limits; the CI account can't log in or be federated; `POST /issues` | |
| 133 | +for a user and a job. | |
| 134 | + | |
| 135 | +## B. Dependency-update pull requests (version updates) | |
| 136 | + | |
| 137 | +A workflow, and a script it runs, shipped in this repository as a | |
| 138 | +starter for any repository here and used first on the forge itself. | |
| 139 | + | |
| 140 | +```yaml | |
| 141 | +name: dependency-updates | |
| 142 | +on: | |
| 143 | + schedule: [{ cron: "23 5 * * 1" }] # weekly | |
| 144 | + workflow_dispatch: | |
| 145 | +permissions: | |
| 146 | + contents: write # push the branch | |
| 147 | + pull-requests: write # open or refresh the PR (part A) | |
| 148 | +jobs: | |
| 149 | + update: | |
| 150 | + runs-on: self-hosted | |
| 151 | + steps: | |
| 152 | + - uses: actions/checkout@v4 | |
| 153 | + - run: <fetch osv-scanner, pinned, as in audit.yml> | |
| 154 | + - run: ./scripts/dependency-update # below | |
| 155 | +``` | |
| 156 | + | |
| 157 | +`scripts/dependency-update` (POSIX sh or a small Elixir escript): | |
| 158 | + | |
| 159 | +1. `osv-scanner scan source -r --format json .` → the packages with a | |
| 160 | + fix available, per lockfile. | |
| 161 | +2. Per ecosystem, bump each to the lowest fixed version: | |
| 162 | + `mix deps.update <pkg>` (Hex, then check the lock reached the fixed | |
| 163 | + version), `cargo update -p <crate> --precise <ver>`, `npm install | |
| 164 | + <pkg>@<ver>` / `npm audit fix` for npm, `go get <mod>@<ver> && go mod | |
| 165 | + tidy`. Ecosystems it doesn't know are listed in the PR body, not | |
| 166 | + touched. | |
| 167 | +3. Run the repository's own test command if it declares one (input | |
| 168 | + `test-command`), so a bump that breaks the build is marked as such. | |
| 169 | +4. Push `deps/security-<ecosystem>` (force-with-lease: the branch is | |
| 170 | + the bot's) and open a PR, or update the open one for that branch — | |
| 171 | + one PR per ecosystem, refreshed in place, never a pile. | |
| 172 | + The body: a table of package, from, to, advisories (OSV links), and | |
| 173 | + what was left alone and why. | |
| 174 | +5. Nothing to fix: close the open one, if any, with a comment. | |
| 175 | + | |
| 176 | +Not in this part: bumps for freshness rather than security (Dependabot's | |
| 177 | +version updates proper). Same machinery with `mix hex.outdated` / | |
| 178 | +`cargo outdated` instead of osv-scanner; a later input | |
| 179 | +(`mode: security | all`). | |
| 180 | + | |
| 181 | +## C. Advisories built into the forge (alerts) | |
| 182 | + | |
| 183 | +### Data | |
| 184 | + | |
| 185 | +OSV (https://osv.dev), which aggregates GitHub's advisories, RustSec, | |
| 186 | +the EEF's for Hex, PyPA, Go, npm and more. Two ways to ask it: | |
| 187 | + | |
| 188 | +- **Mirror (recommended).** A daily job downloads the ecosystem dumps | |
| 189 | + osv-scanner's offline mode uses (`--download-offline-databases`), | |
| 190 | + into `priv`-adjacent storage (`GITGUD_DATA_DIR/osv`). No repository's | |
| 191 | + dependency list ever leaves the instance. | |
| 192 | +- **API.** `api.osv.dev/v1/querybatch` per scan: no storage, but every | |
| 193 | + repository's dependencies go to Google on every push. Opt-in only. | |
| 194 | + | |
| 195 | +Config: `config :git_gud, GitGud.Advisories, source: :mirror | :api | | |
| 196 | +:off` (default `:mirror`), plus the ecosystems to fetch. | |
| 197 | + | |
| 198 | +### Scanning | |
| 199 | + | |
| 200 | +Shell out to osv-scanner rather than parse lockfiles in Elixir: it | |
| 201 | +already reads mix.lock, Cargo.lock, package-lock/yarn/pnpm, go.sum, | |
| 202 | +poetry/uv/Pipfile locks, Gemfile.lock, composer.lock, gradle and maven | |
| 203 | +locks, and its version-range matching per ecosystem is the hard part. | |
| 204 | +Pinned and checksummed in the Dockerfile and the .deb, like any other | |
| 205 | +runtime dependency; absent, the feature reports itself off. | |
| 206 | + | |
| 207 | +- **When:** after a push moves the default branch and touched a file a | |
| 208 | + scanner reads (from the push's changed paths), and nightly for every | |
| 209 | + repository after the mirror refreshes — new advisories arrive without | |
| 210 | + anyone pushing. | |
| 211 | +- **How:** export only the lockfiles at the head commit (`git archive` | |
| 212 | + of the matching paths) into a temp dir, run | |
| 213 | + `osv-scanner scan source -r --offline-vulnerabilities --format json` | |
| 214 | + there with a timeout and no network. Repositories' own files never | |
| 215 | + run as code (no `--call-analysis`). Oban queue `:advisories`, | |
| 216 | + concurrency 2. | |
| 217 | + | |
| 218 | +### Alerts | |
| 219 | + | |
| 220 | +Table `security_alerts`: `repository_id`, `advisory_id` (OSV id, with | |
| 221 | +its GHSA/CVE aliases), `ecosystem`, `package`, `version`, | |
| 222 | +`fixed_versions`, `severity` (CVSS), `manifest_path`, `state` | |
| 223 | +(`open` | `fixed` | `dismissed`), `dismissed_reason`, `dismissed_by_id`, | |
| 224 | +`first_seen_at`, `fixed_at`. A rescan opens what's new, marks `fixed` | |
| 225 | +what's gone, leaves `dismissed` alone (unless the version changes). | |
| 226 | + | |
| 227 | +- **Who sees them:** a repository's writers and admins, and the org's | |
| 228 | + admins — an open alert is a map to what's exploitable, so not every | |
| 229 | + reader of a public repository (GitHub draws the same line). | |
| 230 | +- **Where:** a "Security" tab on the repository | |
| 231 | + (`/r/:owner/:name/security[/:state]`, path segments), a roll-up at | |
| 232 | + `/orgs/:handle/security`, and the dashboard's attention panel. | |
| 233 | +- **Telling people:** a new high or critical alert notifies the | |
| 234 | + repository's admins (in-app, and email where they've allowed it); the | |
| 235 | + rest go in a daily digest. | |
| 236 | +- **Audit log:** dismissals and reopenings | |
| 237 | + (`security_alert.dismissed {advisory, reason}` / `.reopened`), in a new | |
| 238 | + `security` category. | |
| 239 | +- **Fixing:** an alert's page links the update workflow (B) — "Open an | |
| 240 | + update PR" dispatches it — when the repository has one. | |
| 241 | +- **API and CLI:** `GET /api/mobile/v1/repos/:owner/:name/security-alerts` | |
| 242 | + behind a new `security:read` scope, `gg security`. | |
| 243 | + | |
| 244 | +## Phasing (each ships on its own) | |
| 245 | + | |
| 246 | +0. `audit.yml` for the forge itself. **Done.** | |
| 247 | +1. **A1** — `issues` / `pull-requests` job permissions, job tokens on the | |
| 248 | + mobile API, the CI account, `POST /issues` and `gg issue create`. | |
| 249 | +2. **A2** — the one-hop loop guard and per-run limits. | |
| 250 | +3. **B** — `scripts/dependency-update` and the workflow, on this | |
| 251 | + repository; then documented as a starter on `/run-your-own`. | |
| 252 | +4. **C1** — osv-scanner in the image, scan on push, `security_alerts`, | |
| 253 | + the repository's Security tab. | |
| 254 | +5. **C2** — the OSV mirror, nightly rescans, notifications and digest. | |
| 255 | +6. **C3** — the org roll-up, dismissals in the audit log, the API and | |
| 256 | + `gg security`, the "open an update PR" link. | |
| 257 | + | |
| 258 | +## Done when | |
| 259 | + | |
| 260 | +`mix precommit` passes with no new warnings; one commit per step; | |
| 261 | +nothing pushed without asking (pushing deploys). | |
| 262 | + | |
| 263 | +## Open questions | |
| 264 | + | |
| 265 | +1. A forge-wide CI account (recommended), or one per organization, or | |
| 266 | + `author_id: nil` with a job label? | |
| 267 | +2. Should job-token PRs trigger `pull_request` workflows, with the | |
| 268 | + one-hop guard (recommended), or nothing, as on GitHub? | |
| 269 | +3. Allow `pull-requests: write` to approve or merge, ever? Recommended: | |
| 270 | + never. | |
| 271 | +4. Alerts' visibility: writers and admins (recommended), or anyone who | |
| 272 | + can read the repository, as for public advisories? | |
| 273 | +5. OSV source default: mirror (recommended), API, or off until an | |
| 274 | + operator turns it on? | |
| 275 | +6. Ship osv-scanner in the release image and .deb (recommended), or | |
| 276 | + require operators to install it? | |
| 277 | +7. Notification policy: immediate for high/critical and a digest for the | |
| 278 | + rest (recommended), or a per-repository setting from the start? | |
| 279 | +8. Freshness updates (Dependabot's version updates proper) in B's first | |
| 280 | + cut, or security-only first (recommended)? | |
| 281 | +9. Android: generate Gradle lockfiles (`dependencyLocking`) so | |
| 282 | + osv-scanner can read the app, or leave it uncovered? |
Parents: b4052ae