146 lines
8.0 KiB
YAML
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
|