# CVE triage bot (R-VULN-05, R-VULN-07; docs/17 "The triage bot"; ADR-0027). Same shape as # renovate.yaml: scheduled, loud on a missing credential, never concurrent with itself, and it # records what it did in the job summary. The logic and its reasoning live in # scripts/cve_triage.py; this file only supplies the image, the code, the credential and the # schedule. # # WHY IT CANNOT SUCCEED SILENTLY. A skipped Gitea job reports `success` (CLAUDE.md ยง2), so the # token preflight exits 1 rather than skipping, and an unreachable scanner fails the script # rather than skipping. The heartbeat POST is the script's last action, so VulnTriageBotSilent # fires for every run that did not get all the way through, and when the schedule stops firing. # # THE ONE SECRET: RENOVATE_TOKEN, deliberately reused (ADR-0027). It acts as renovate-bot, which # is also what authored the dashboard this edits. It needs read:repository and write:issue. name: cve-triage on: schedule: # 07:15 UTC, one hour before Renovate's 08:15 (renovate.yaml), so a tick is read the same # morning, and clear of its run so the bot's edit cannot race Renovate's rewrite of the body. - cron: "15 7 * * *" workflow_dispatch: inputs: dry_run: description: "Dry run: read everything, write nothing, print counts." required: false default: "true" concurrency: group: cve-triage cancel-in-progress: false jobs: triage: runs-on: ubuntu-latest # THE TOOLBOX, the image gate.yml already runs, pinned by digest. A job here has no Docker # daemon, so a job container is the only way to choose an image. The toolbox provides # python3, git, curl and jq, pulls anonymously like the gate's, and is normally already in # the DinD store. It has no Node, so actions/checkout cannot run; a public python image has # neither Node nor git and would add a second image to keep pinned. The cost is a cold pull # after a runner restart, which the gate pays anyway. container: # renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main image: gitea.home.keithjackson.dev/home-network/toolbox@sha256:83a3501efe34fa0b039e3f0d360738686a0f59109866c49779768f5d5ed0ef81 # Without a bound, a wedged run would hold the concurrency group and block every later run. timeout-minutes: 15 steps: - name: Preflight - the token must exist, and its absence must be LOUD shell: bash env: RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} run: | set -euo pipefail if [ -z "${RENOVATE_TOKEN:-}" ]; then echo "::error::RENOVATE_TOKEN is not set on this repository." >&2 echo "A skipped job reports success, so this fails on purpose: without the token no dashboard is maintained and no approval is requested. Add the Actions secret RENOVATE_TOKEN (scopes read:repository, write:issue)." >&2 exit 1 fi echo "RENOVATE_TOKEN is present (value not printed)." - name: Preflight - Gitea is reachable and the token can do this job shell: bash env: RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} # GETs only. The token reaches curl on stdin, never argv. A 401 is the token's value, a 403 # its scope; the script's own errors would only show a bare status later. run: | set -uo pipefail GITEA=https://gitea.home.keithjackson.dev REPO=Home-Network/home-network-k3s-impl fail=0 body=$(mktemp) fetch() { printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \ | curl -sS --max-time 30 --config - -o "$body" -w '%{http_code}' "$GITEA$1" || true } probe() { # local code code=$(fetch "$2") if [ "$code" = 200 ]; then echo "ok $1" else fail=1 echo "::error::$1: GET ${2%%\?*} -> $code" fi } probe "read:repository, Gitea reachable" "/api/v1/repos/$REPO" if [ "$(jq -r '.permissions.push // false' "$body" 2>/dev/null)" != "true" ]; then fail=1 echo "::error::renovate-bot has no write access to $REPO, so it cannot create labels or edit issues." fi probe "read:issue" "/api/v1/repos/$REPO/labels?limit=1" probe "Renovate's dashboard readable" "/api/v1/repos/$REPO/issues/91" # A GET cannot prove a write scope; Gitea's scope refusal lists the token's scopes # (names only). Same technique as renovate.yaml, advisory if the message format changes. fetch "/api/v1/admin/orgs?limit=1" > /dev/null scopes=$(jq -r '.message // empty' "$body" 2>/dev/null | sed -n 's/.*token scope=//p') if [ -n "$scopes" ]; then case ",$scopes," in *",write:issue,"*|*",all,"*) echo "ok write:issue" ;; *) echo "::error::scope write:issue is missing (token scope=$scopes)"; fail=1 ;; esac else echo "::warning::could not read the token's scope list; write:issue is UNVERIFIED" fi rm -f "$body" [ "$fail" -eq 0 ] # Plain git for the same reason as gate.yml: the toolbox has no Node. One commit is enough, # and the job token goes to the single fetch only, never into .git/config. - name: Checkout the script (git, one commit) shell: bash env: GIT_TOKEN: ${{ github.token }} SERVER_URL: ${{ github.server_url }} REPOSITORY: ${{ github.repository }} SHA: ${{ github.sha }} run: | set -euo pipefail umask 0022 git config --global --add safe.directory "$PWD" git init -q . git remote add origin "${SERVER_URL}/${REPOSITORY}" auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \ GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \ git -c protocol.version=2 fetch --quiet --depth=1 --no-tags origin \ "+${SHA}:refs/remotes/checkout/head" unset auth GIT_TOKEN git checkout -q --force --detach "$SHA" [ "$(git rev-parse HEAD)" = "$SHA" ] - name: Triage shell: bash env: RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} GITEA_URL: https://gitea.home.keithjackson.dev GITEA_REPOSITORY: Home-Network/home-network-k3s-impl RENOVATE_DASHBOARD_ISSUE: "91" VULN_API_URL: http://vuln-scanner.vuln-scanner.svc.cluster.local:8081 # The Service's pinned spec.clusterIP (platform/vuln-scanner/service.yaml), used only # when the name above fails to resolve. Keep the two in step. VULN_API_FALLBACK_URL: http://10.43.210.10:8081 VULN_PAGE_URL: https://cve.home.keithjackson.dev # Anything other than an explicit false on a dispatch is a dry run; a schedule is live. DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }} run: python3 .gitea/workflows/scripts/cve_triage.py - name: Summary if: always() run: | { echo "## CVE triage" echo echo "- trigger: \`${{ github.event_name }}\`" echo "- dry run requested: \`${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != 'false' && 'true' || 'false' }}\`" echo "- outcome: \`${{ job.status }}\`" echo echo "Counts only: which CVE lands where is on the CVE page, never here." } >> "$GITHUB_STEP_SUMMARY"