160 lines
7.6 KiB
YAML
160 lines
7.6 KiB
YAML
# 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() { # <what it proves> <path>
|
|
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"
|