CI: a nightly osv-scanner audit of the forge's lockfiles; plan for dependency security

b05f3a1 · Gabriel Morell · 2026-10-22 19:37

6 files +324 -0
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