Files
homelab/.woodpecker/update-audit.yml
T
claude-bot 4403261c2f
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
chore(ci): bump ci-claude image to 2.1.306
2026-10-21 07:53:34 +00:00

146 lines
8.0 KiB
YAML

# Weekly infrastructure update audit.
#
# An AI agent checks the components that are NOT covered by Flux
# image-automation (which bumps container tags) or the daily security-scan
# (which owns CVEs): Talos / Kubernetes / Flux version pins, Helm chart
# minor/major bumps held back by `~MAJOR.MINOR.0` constraints, images
# excluded from automation, and Ansible/OpenTofu dependency drift.
#
# The agent is READ-ONLY (no Edit, no repo writes). It only produces a
# report; the pipeline posts it to a single persistent Gitea issue.
#
# Triggering:
# - cron : a cron NAMED exactly "update-audit". Crons carry no variables —
# `repo cron add` takes name, branch, schedule and enabled — so the
# name filter is the only thing that can select a workflow for a
# scheduled run:
# woodpecker repo cron add --repo konairius/homelab \
# --name update-audit --branch main --schedule '@weekly'
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> update-audit
# where <n> is an existing pipeline to promote.
#
# NOTE: e2e.yml runs on an explicit list of cron names that includes this one,
# so a weekly e2e run piggybacks on the audit (it was a bare `event: cron`
# until the 15-minute alert-monitor cron made that untenable).
when:
- event: cron
cron: update-audit
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "update-audit"'
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
# Dedicated identity: update-audit-bot, PAT scoped to write:issue ONLY.
#
# This resolves the gap #197 tracked. The history: security_bot_token is
# scoped for code/PRs, so its issue POST returned 403 and the audit ran
# green while creating nothing; the fix at the time was the admin-owned
# gitea_token, which handed a broad credential to a WebFetch-capable
# agent that reads untrusted third-party release pages. That is now a
# narrow token that cannot do anything but write issues — verified 403
# on /user/repos and /admin/users. See #330.
#
# The other mitigations still apply and are still load-bearing: tool
# scoping (no WebSearch), the Read sandbox in settings.json, and the
# prompt hardening that treats fetched content as untrusted data.
GITEA_TOKEN:
from_secret: update_audit_bot_token
commands:
- |
# Hand off to Claude Code in headless mode. Same auth model as the
# security agent (subscription OAuth token, not --bare). Tool
# allowlist is read-only + web: the agent reads the repo for current
# pins, fetches upstream releases, and writes a report to /tmp. It
# has NO Edit/Bash, so it cannot mutate the repo. Prompt is piped via
# stdin (see security-scan.yml for why -p "$(cat ...)" is avoided).
#
# Security hardening (gitea issue #197 — prompt-injection exfil via
# the untrusted release pages this agent fetches):
# - WebSearch is dropped. It can't be domain-scoped (unlike
# WebFetch) and the prompt already gives explicit source URLs or
# a well-known registry (Artifact Hub / chart source repo) for
# everything it needs to look up.
# - --settings loads ci/agents/update-audit/settings.json, which
# denies Read on /proc, /root, /home, /etc, and this repo's own
# .git/ — closing off the credential-file and env-dump read
# paths a prompt-injected instruction could otherwise try.
# (Write path-scoping was tested and found NOT to be enforced
# under --permission-mode acceptEdits in the current Claude Code
# CLI, so it's intentionally not relied on here; the pipeline
# only ever trusts /tmp/update-audit.md regardless of what else
# the agent writes.)
# - prompt.md now tells the agent fetched content is untrusted
# data, not instructions, and to never echo it verbatim.
# A full WebFetch domain allowlist was considered and rejected: the
# Helm-chart-version check legitimately needs to reach whichever
# chart-repo domain each HelmRelease's own (already-trusted)
# HelmRepository/OCIRepository URL points at — a fixed allowlist
# across ~20 charts would need updating every time a chart is added
# and would silently stop auditing anything not yet listed.
# Model chosen live from the catalog (not hardcoded, no family assumed) —
# see the header of scripts/ci/pick-model.sh. Empty MODEL -> CLI default.
MODEL=$$(sh scripts/ci/pick-model.sh "weekly, read-only homelab infrastructure update audit: compare Talos/Kubernetes/Flux/Helm/Ansible/OpenTofu version pins against upstream releases and write a drift report; moderate-difficulty analysis, no code changes")
if [ -n "$$MODEL" ]; then echo "update-audit: selected model $$MODEL"; else echo "update-audit: no live catalog — using the CLI default model"; fi
cat ci/agents/update-audit/prompt.md | claude $${MODEL:+--model $$MODEL} \
--permission-mode acceptEdits \
--allowedTools "Read,Glob,Grep,Write,WebFetch" \
--settings ci/agents/update-audit/settings.json \
-p
- |
# Bail cleanly if the agent produced no report.
if [ ! -s /tmp/update-audit.md ]; then
echo "Agent wrote no report (/tmp/update-audit.md missing or empty) — nothing to post."
exit 0
fi
echo "=== Report preview ==="
head -40 /tmp/update-audit.md
- |
# Post the report to a single persistent Gitea issue. Find the open
# issue by its stable title; update its body and add a dated comment
# if it exists, otherwise create it.
#
# Every write captures the HTTP status explicitly and fails the step
# on any non-2xx — a piped `... | curl -sf | jq` would mask a failed
# POST behind the trailing jq's exit code (the pipe's status is the
# LAST command), which is exactly how the first run went green while
# silently creating no issue.
set -eu
TITLE="Infrastructure update audit"
STAMP=$$(date -u +%Y-%m-%dT%H:%M:%SZ)
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
# write <method> <url> <payload-file> -> prints HTTP code, fails non-2xx
gitea_write() {
code=$$(curl -s -o /tmp/gitea-resp.json -w '%{http_code}' \
-X "$$1" -H "Authorization: token $$GITEA_TOKEN" \
-H "Content-Type: application/json" --data @"$$3" "$$2")
echo "$$1 $$2 -> HTTP $$code"
if [ "$$code" -lt 200 ] || [ "$$code" -ge 300 ]; then
echo "Gitea API error body:"; cat /tmp/gitea-resp.json; echo
return 1
fi
}
ISSUE=$$(curl -sfL -H "Authorization: token $$GITEA_TOKEN" \
"$$API/issues?state=open&type=issues&limit=50" \
| jq -r --arg t "$$TITLE" 'map(select(.title == $$t)) | .[0].number // empty')
if [ -n "$$ISSUE" ]; then
echo "Updating existing issue #$$ISSUE"
jq -n --arg body "$$(cat /tmp/update-audit.md)" '{body: $$body}' > /tmp/gitea-patch.json
gitea_write PATCH "$$API/issues/$$ISSUE" /tmp/gitea-patch.json
jq '{number, html_url}' /tmp/gitea-resp.json
jq -n --arg body "🔄 Re-ran $$STAMP — report above refreshed." '{body: $$body}' > /tmp/gitea-comment.json
gitea_write POST "$$API/issues/$$ISSUE/comments" /tmp/gitea-comment.json
else
echo "Creating new audit issue"
jq -n --arg title "$$TITLE" --arg body "$$(cat /tmp/update-audit.md)" \
'{title: $$title, body: $$body}' > /tmp/gitea-create.json
gitea_write POST "$$API/issues" /tmp/gitea-create.json
jq '{number, html_url, title}' /tmp/gitea-resp.json
fi