diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 11d47992..b8b59cdc 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -161,3 +161,147 @@ jobs: # without first re-evaluating ADR-011. if: always() run: rm -f .env.staging + + npm-audit: + # Independent parallel job — a deploy failure cannot mask the audit signal + # and a clean audit cannot hide a broken deploy. Intentionally no `needs:`. + # + # Scans dev deps too (no --omit=dev), which is deliberately broader than the + # PR gate (ci.yml §Security audit) that uses --omit=dev. A nightly broader + # result is NOT a PR gate failure — it catches dev-tooling advisories (esbuild, + # Vite, etc.) early. See docs/infrastructure/ci-gitea.md §Nightly audit vs PR gate. + # + # Required Gitea secrets: + # NIGHTLY_AUDIT_TOKEN — PAT with issues scope only. An issues-only token + # means a leak via logs/process-args cannot push + # branches, open PRs, or read repo contents (ADR-041). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Assert jq is available + run: which jq || sudo apt-get install -y jq + + - name: Run npm audit and file tracking issue on findings + # Never run under set -x — NIGHTLY_AUDIT_TOKEN in env would leak to logs. + env: + NIGHTLY_AUDIT_TOKEN: ${{ secrets.NIGHTLY_AUDIT_TOKEN }} + run: | + MARKER="Nightly npm audit: high-severity advisory" + GITEA_URL="${{ github.server_url }}" + REPO="${{ github.repository }}" + RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}" + + # --- Self-test (mirrors ci.yml §Assert pattern) --- + # Tests the exact jq test() call used in the dedupe step, before any + # API call, so a broken matcher fails loudly early rather than silently + # opening duplicate issues. Proves the regex only — create-vs-update + # decision is exercised by the workflow_dispatch AC. + echo "{\"title\": \"${MARKER}\"}" \ + | jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \ + || { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; } + echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \ + | jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \ + || { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; } + echo "Self-test passed." + + # --- Run audit --- + # No npm ci — audit reads only the lockfile (no network, no install). + set +e + (cd frontend && npm audit --audit-level=high --json > /tmp/audit.json) + AUDIT_EXIT=$? + set -e + + if [ "$AUDIT_EXIT" -ne 0 ]; then + # --- Build issue body with jq (never string-concat advisory text) --- + # Advisory overview/title text is registry-controlled; string-concat + # would be an injection/escaping vector into the API body. Truncate + # raw excerpt to 500 chars so a pathological overview can't produce + # a multi-MB PATCH body. + ISSUE_BODY=$(jq -r \ + --arg run_url "$RUN_URL" \ + ' + (.vulnerabilities // {}) as $vulns | + ($vulns | to_entries | + map(select(.value.severity == "high" or .value.severity == "critical")) | + map("- **" + .key + "** (" + .value.severity + ")") | + if length > 0 then join("\n") else "_See raw output for details._" end) as $pkg_list | + "## npm audit: high/critical advisories\n\n" + $pkg_list + + "\n\n**Run:** " + $run_url + + "\n\n
Raw audit excerpt (first 500 chars)\n\n```\n" + + (tostring | .[0:500]) + + "\n```\n\n
" + ' /tmp/audit.json) + + # --- Dedupe: fetch open security issues, match by title marker --- + # Renovate vuln PRs also carry the "security" label, so >1 open + # "security" issue WILL occur. Title-match (not just label) ensures + # we deduplicate only our own tracking issue. + OPEN_ISSUES=$(curl -sf \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + "${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50") + + MATCHED=$(echo "$OPEN_ISSUES" | jq \ + --arg m "$MARKER" \ + '[.[] | select(.title | test($m; "i"))] | sort_by(.created_at)') + MATCH_COUNT=$(echo "$MATCHED" | jq 'length') + + if [ "$MATCH_COUNT" -gt 0 ]; then + # Patch the oldest matched issue (append run URL to body). + ISSUE_NUMBER=$(echo "$MATCHED" | jq -r '.[0].number') + EXISTING_BODY=$(echo "$MATCHED" | jq -r '.[0].body') + NEW_BODY=$(jq -n \ + --arg existing "$EXISTING_BODY" \ + --arg run_url "$RUN_URL" \ + '$existing + "\n\n---\n\nUpdated by run: " + $run_url') + PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}') + curl -sf -X PATCH \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null + echo "Updated tracking issue #${ISSUE_NUMBER}" + else + # Closed prior issue that recurs → new issue (not reopened). + # A re-opened issue would obscure when the advisory was re-discovered. + PAYLOAD=$(jq -n \ + --arg title "$MARKER" \ + --arg body "$ISSUE_BODY" \ + '{"title": $title, "body": $body}') + CREATED=$(curl -sf -X POST \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "${GITEA_URL}/api/v1/repos/${REPO}/issues") + NEW_NUMBER=$(echo "$CREATED" | jq -r '.number') + echo "Opened new tracking issue #${NEW_NUMBER}" + + # Labels are ignored on issue create in Gitea — add in a follow-up call. + LABEL_IDS=$(curl -sf \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + "${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \ + | jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]') + curl -sf -X POST \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"labels\": $LABEL_IDS}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null + fi + + exit "$AUDIT_EXIT" + + else + # --- Heartbeat: proves the job ran and found nothing --- + # "No issue created" is only meaningful evidence when paired with a + # visible positive signal. Without this, a never-ran job is + # indistinguishable from a clean run. + # + # $GITHUB_STEP_SUMMARY availability is unproven on this runner + # (act_runner populates it, but this is the first run to verify it). + # Guard before use so an unset variable does not fail the clean-path. + MSG="✅ npm audit clean $(date -u)" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo "$MSG" >> "$GITHUB_STEP_SUMMARY" + fi + echo "$MSG" + fi diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml new file mode 100644 index 00000000..da9e454d --- /dev/null +++ b/.gitea/workflows/renovate.yml @@ -0,0 +1,44 @@ +name: Renovate + +# Runs Renovate daily to surface newly-published advisories via OSV.dev +# (osvVulnerabilityAlerts) and open routine update PRs on a weekly batch +# schedule (see renovate.json §schedule). Security/vulnerability PRs are +# raised immediately regardless of the weekly schedule window. +# +# Required Gitea secrets (see docs/adr/041-renovate-runner-setup.md): +# RENOVATE_TOKEN — PAT with scopes: contents + pull_request + issues +# Belongs to a dedicated bot account. Branch protection +# on main must forbid this bot pushing directly. +# +# Platform config is injected via env vars below; the renovate.json in the +# repo root carries only dependency rules (no platform/endpoint/repos). +# +# Digest pin: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd +# corresponds to release v46.1.15. Update by bumping both the digest and the +# renovate-version when Renovate publishes a new release. Renovate itself +# will open a PR to bump this digest once it runs. + +on: + schedule: + - cron: "0 3 * * *" # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day + workflow_dispatch: + +jobs: + renovate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Renovate + # Pinned by digest — this action holds contents+pull_request+issues + # scopes; an unpinned tag is a supply-chain risk (see ADR-041). + uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15 + with: + configurationFile: renovate.json + token: ${{ secrets.RENOVATE_TOKEN }} + renovate-version: "46.1.15" + env: + RENOVATE_PLATFORM: gitea + RENOVATE_ENDPOINT: https://git.raddatz.cloud + RENOVATE_REPOSITORIES: '["marcel/familienarchiv"]' + LOG_LEVEL: info diff --git a/docs/adr/041-renovate-runner-setup.md b/docs/adr/041-renovate-runner-setup.md new file mode 100644 index 00000000..41a17fcd --- /dev/null +++ b/docs/adr/041-renovate-runner-setup.md @@ -0,0 +1,123 @@ +# ADR-041 — Renovate runner stand-up: two-token model, OSV surfacing, digest pinning + +**Date:** 2026-06-13 +**Status:** Accepted +**Issue:** [#818](https://git.raddatz.cloud/marcel/familienarchiv/issues/818) + +--- + +## Context + +Issue #817 (esbuild/cookie advisory) revealed that `main` had no early-warning +mechanism for newly-published advisories. An advisory landed against already-pinned +versions, turned the `npm audit --audit-level=high --omit=dev` gate red on `main`, +and then ambushed the next unrelated PR (#774). The author who hit it did not cause +it and had no warning. + +`renovate.json` existed but `renovatebot` had never actually run: there was no +`.gitea/workflows/renovate.yml` and zero Renovate-authored PRs in the repo's entire +history. The three `packageRules` (bucket4j / tiptap / privileged-digest) were +silently inert. + +This ADR records the **negative space** — why specific design choices were made, +so future maintainers do not "tidy up" toward a worse outcome. + +--- + +## Decision + +### Why there is no auto-provided `GITEA_TOKEN` + +Self-hosted Gitea runners do not auto-inject a `GITEA_TOKEN` equivalent. +`docs/infrastructure/ci-gitea.md` (and its current line ~251) explicitly states the +token "must be created manually." No existing workflow in this repo references +`GITEA_TOKEN` for API calls — only for container registry auth (`docker login`). +Both `RENOVATE_TOKEN` and `NIGHTLY_AUDIT_TOKEN` must be manually provisioned as +Gitea secrets by a repository admin. + +### Why two tokens, not one + +The two jobs have different blast radii on token compromise: + +| Token | Scopes | Used by | +|-------|--------|---------| +| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | Renovate — must read/write files and open PRs | +| `NIGHTLY_AUDIT_TOKEN` | `issues` only | Nightly audit — only needs to file a tracking issue | + +The nightly job's token appears in step `env:` and is passed to `curl -H`. A leak via +runner logs, process arguments, or a misconfigured step would expose the token. +An `issues`-only token cannot push branches, open PRs, or read repository contents — +the leaked token's blast radius is limited to creating/editing issues. + +A single broad token would give any leak path full `contents` + `pull_request` write +access to the repository. That risk is asymmetric with the upside (one fewer secret). + +Both tokens belong to one dedicated bot account (consistent authorship; one identity +to audit and rotate). **Branch protection on `main` must forbid the bot pushing +directly**, because a `contents`-scoped token can push to any unprotected branch. + +### Why the Renovate action is digest-pinned + +`renovatebot/github-action` executes with the `RENOVATE_TOKEN` in scope. That token +carries `contents` + `pull_request` + `issues` — enough to read files, open PRs, and +write issues. An unpinned `@v40` tag can be re-pointed by the upstream maintainer +(or a compromised maintainer account) at any time. A pinned digest (`@`) cannot +be silently modified; the SHA is immutable. This is the same threat model applied to +all privileged CI steps in this repo (see the `matchFileNames` rule in `renovate.json` +for `.gitea/workflows/**`). + +Renovate itself will open a PR to bump the digest when a new release ships, which is +the intended update path. + +### Why `osvVulnerabilityAlerts` is the load-bearing detector on Gitea + +Renovate's `vulnerabilityAlerts` config key triggers off a *platform* vulnerability +graph. GitHub exposes the GitHub Advisory Database via its API; **Gitea does not +expose an equivalent vulnerability graph**. On self-hosted Gitea, `vulnerabilityAlerts` +is effectively a label carrier — it attaches the configured labels to PRs that +`osvVulnerabilityAlerts` already detected, but it is not an independent detector. + +`osvVulnerabilityAlerts: true` is the load-bearing flag: Renovate queries +[OSV.dev](https://osv.dev) directly (platform-agnostic). The runner host must be able +to reach OSV.dev over HTTPS — if egress is filtered, allow `osv.dev:443` or the flag +silently no-ops. + +### Why the root `schedule` does not mute security PRs + +`"schedule": ["before 6am on monday"]` in `renovate.json` batches **routine** dependency +updates (version bumps outside any security context) to a weekly window. This reduces +noise from routine update PRs while still allowing review before merge. + +**Security and vulnerability PRs bypass the schedule by design** — Renovate raises +them immediately regardless of the schedule window. A future "tidy-up" that removes +or widens the schedule cannot mute vulnerability alerts; this is worth stating +explicitly to prevent that misunderstanding. + +### Why `lockFileMaintenance` has no `automerge` + +`lockFileMaintenance` refreshes transitive pins weekly so the dependency tree drifts +into fewer advisories over time. It is explicitly set without `automerge: true` because +a weekly transitive pin refresh can silently break the build if a transitive dep +introduces a breaking change. These PRs are small and should be reviewed. + +### Why there is no entry in `l2-containers.puml` + +`docs/architecture/c4/l2-containers.puml` documents long-lived infrastructure +containers (services that run continuously). Renovate is a scheduled CI job that runs +on a Gitea Actions runner and exits — it is not a long-lived container. Adding it to +the container diagram would misrepresent the architecture. This omission is deliberate, +not an oversight. + +--- + +## Consequences + +- Newly-published advisories against our frontend dependencies are surfaced within + one day (daily Renovate cron) rather than at the next contributor PR. +- A nightly `npm audit` job provides an independent signal for dev-dependency advisories + that Renovate may not cover via OSV. +- Two secrets (`RENOVATE_TOKEN`, `NIGHTLY_AUDIT_TOKEN`) must be manually provisioned + and rotated annually (or on suspected compromise). See + `docs/infrastructure/ci-gitea.md` for the runbook. +- The bot account must be kept active and branch protection on `main` must forbid + it pushing directly. These are operational prerequisites, not code invariants. diff --git a/docs/infrastructure/ci-gitea.md b/docs/infrastructure/ci-gitea.md index 6d99f694..871eec78 100644 --- a/docs/infrastructure/ci-gitea.md +++ b/docs/infrastructure/ci-gitea.md @@ -462,3 +462,82 @@ jobs: name: e2e-results path: frontend/test-results/e2e/ ``` + +--- + +## Renovate + Nightly Audit — Token Model + +> See [ADR-041](../adr/041-renovate-runner-setup.md) for full rationale. + +### Two-token model + +This repo uses two separate tokens for automated dependency work, both manually +provisioned as Gitea secrets. There is **no auto-provided `GITEA_TOKEN`** on +self-hosted Gitea runners — it must be created manually (see §Context Variable Names +table above). + +| Secret | Scopes | Job | Reason | +|--------|--------|-----|--------| +| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | `renovate.yml` | Renovate needs to read/write files and open PRs | +| `NIGHTLY_AUDIT_TOKEN` | `issues` only | `nightly.yml` → `npm-audit` job | Only needs to file a tracking issue; an `issues`-only token cannot push branches or read contents — limits blast radius on leak | + +Both tokens belong to a single dedicated bot account. **Branch protection on `main` +must forbid the bot pushing directly**, because a `contents`-scoped token can push +to any unprotected branch. + +### PAT rotation cadence + +Rotate both tokens: +- **Annually** (calendar reminder) +- **Immediately** on suspected compromise (runner log leak, accidental `set -x`, etc.) + +Tokens are stored exclusively as Gitea secrets. Never commit them to `.env` files or +log them. The nightly audit step passes its token via `env:` at the step level, reads +it as `$NIGHTLY_AUDIT_TOKEN` in the shell, and never runs the API `curl` under +`set -x`. + +### OSV vs platform alerts on Gitea + +Renovate's `vulnerabilityAlerts` config key requires a *platform* vulnerability graph. +**Gitea does not expose one** — it has no equivalent to the GitHub Advisory Database +API. On this runner, `vulnerabilityAlerts` is a label carrier only: it attaches +`security` and `P1-high` labels to PRs that `osvVulnerabilityAlerts` already raised. + +`osvVulnerabilityAlerts: true` is the load-bearing detector. Renovate queries +[OSV.dev](https://osv.dev) directly, which works regardless of platform. The runner +host must be able to reach `osv.dev:443`. If egress is filtered and OSV.dev is +unreachable, the flag silently no-ops — verify egress when standing up the runner. + +### Nightly audit vs PR gate (divergence) + +| Gate | Command | Dev deps | When | +|------|---------|----------|------| +| PR gate (`ci.yml`) | `npm audit --audit-level=high --omit=dev` | ❌ excluded | Every PR | +| Nightly audit (`nightly.yml`) | `npm audit --audit-level=high` | ✅ included | Nightly + `workflow_dispatch` | + +The nightly job is **deliberately broader** — it catches dev-tooling advisories +(esbuild, Vite, vitest, etc.) that the PR gate ignores. A red nightly audit job does +**not** mean the PR gate is broken; the two signals are independent. + +### Runbook: nightly-opened tracking issue + +When the `npm-audit` job opens or updates the tracking issue +"Nightly npm audit: high-severity advisory": + +1. **Triage severity.** Check the advisory page (link in the issue body). Is it + exploitable in production? A dev-only dep (e.g. esbuild, prettier) has no + production attack surface — treat as low urgency. + +2. **Pin or upgrade.** If a non-breaking upgrade is available, update + `frontend/package.json` and regenerate the lockfile. Open a PR. + +3. **Override if justified.** If the advisory does not apply (dev-only dep, no + exploitable path), add an `npm audit` override in `package.json`: + ```json + "overrides": { "esbuild": ">=0.25.4" } + ``` + Document the rationale in the PR body. See #817 for the reference decision tree. + +4. **Close the tracking issue** once the advisory is resolved or overridden and the + nightly job runs clean (verify via the `✅ npm audit clean` heartbeat in the job + summary). diff --git a/docs/infrastructure/self-hosted-catalogue.md b/docs/infrastructure/self-hosted-catalogue.md index fc9a1c61..c197db0c 100644 --- a/docs/infrastructure/self-hosted-catalogue.md +++ b/docs/infrastructure/self-hosted-catalogue.md @@ -151,7 +151,7 @@ receivers: name: Renovate on: schedule: - - cron: '0 3 * * 1' # every Monday at 3am + - cron: '0 3 * * *' # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day workflow_dispatch: jobs: @@ -160,32 +160,58 @@ jobs: steps: - uses: actions/checkout@v4 - name: Run Renovate - uses: renovatebot/github-action@v40 + # Pin by digest — this action holds contents+pull_request+issues token; + # an unpinned tag is a supply-chain risk. Update digest + renovate-version + # together when Renovate publishes a new release. + uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15 with: configurationFile: renovate.json - token: ${{ secrets.GITEA_TOKEN }} - renovate-version: latest + token: ${{ secrets.RENOVATE_TOKEN }} + renovate-version: "46.1.15" + env: + RENOVATE_PLATFORM: gitea + RENOVATE_ENDPOINT: https://gitea.example.com # replace with your Gitea URL + RENOVATE_REPOSITORIES: '["org/repo"]' # replace with your repo slug + LOG_LEVEL: info ``` +> **Token:** `RENOVATE_TOKEN` must be a PAT on a dedicated bot account with scopes +> `contents` + `pull_request` + `issues`. **Do not reuse** `GITEA_TOKEN` — that variable +> is not auto-provided on self-hosted Gitea runners and must be manually created anyway; +> using a single broad token violates least-privilege. See ADR-041. + ### Renovate Configuration +The `renovate.json` in the repo root carries only dependency rules — platform and +endpoint config is injected via `env:` in the workflow above. Keep the two concerns +separate so the config file remains portable. + ```json -// renovate.json { - "platform": "gitea", - "endpoint": "https://gitea.example.com", - "repositories": ["org/familienarchiv"], - "automerge": true, - "automergeType": "pr", + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "osvVulnerabilityAlerts": true, + "dependencyDashboard": true, + "schedule": ["before 6am on monday"], + "vulnerabilityAlerts": { + "labels": ["security", "P1-high"] + }, + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 6am on monday"] + }, "packageRules": [ { - "matchUpdateTypes": ["patch"], - "automerge": true + "matchPackageNames": ["com.example:my-dep"], + "automerge": true, + "matchUpdateTypes": ["patch"] } ] } ``` +> **Do not add `automerge: true` at the root.** Security and digest-bump PRs should +> always be reviewed manually. Per-rule `automerge` on patch-level routine deps is fine. + --- ## Secrets Management -- age + git-crypt diff --git a/renovate.json b/renovate.json index 2b4af645..bae03932 100644 --- a/renovate.json +++ b/renovate.json @@ -1,5 +1,15 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "osvVulnerabilityAlerts": true, + "dependencyDashboard": true, + "schedule": ["before 6am on monday"], + "vulnerabilityAlerts": { + "labels": ["security", "P1-high"] + }, + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 6am on monday"] + }, "packageRules": [ { "description": "bucket4j-core is manually pinned outside the Spring BOM — track patch auto-merge, minor/major as PRs.", @@ -9,13 +19,13 @@ "matchUpdateTypes": ["patch"] }, { - "matchPackagePatterns": ["^@tiptap/"], + "matchPackageNames": ["/^@tiptap/"], "groupName": "tiptap", "automerge": false }, { "description": "Digest bumps for images used in privileged CI steps (--privileged --pid=host) must be reviewed manually — a compromised image has root-equivalent host access. Covers .gitea/actions/** too: the reload-caddy alpine digest now lives in a composite action (#603).", - "matchPaths": [".gitea/workflows/**", ".gitea/actions/**"], + "matchFileNames": [".gitea/workflows/**", ".gitea/actions/**"], "matchUpdateTypes": ["digest"], "automerge": false, "reviewersFromCodeOwners": false