Files
homelab/.woodpecker/security-scan.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

162 lines
8.1 KiB
YAML

# Trivy scan of the IaC tree and every image referenced from ansible/, with an
# agent pass that opens a PR for CRITICAL/HIGH findings.
#
# Triggers:
# - cron : the named `daily-security-scan` cron, matched by NAME.
# Crons cannot carry variables — `woodpecker-cli repo cron add`
# has no --var — so a variable gate can never match a cron.
# - deployment : woodpecker-cli pipeline deploy konairius/homelab <n> security-scan
# The target rides in pipeline metadata as
# CI_PIPELINE_DEPLOY_TARGET, which the workflow-root filter
# does see, unlike manual-run variables on Woodpecker 3.18
# (#582).
when:
- event: cron
cron: daily-security-scan
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "security-scan"'
steps:
- name: iac-scan
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
commands:
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
- name: image-scans
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
commands:
- trivy image --download-db-only
- |
# Extract all Docker images from ansible configs and docker-compose templates
find ansible/ -name '*.yml' -o -name '*.yaml' -o -name '*.j2' | xargs grep -h 'image:' \
| sed 's/.*image:\s*//' | tr -d '"' | tr -d "'" \
| sed 's/\$${[^}]*:-\([^}]*\)}/\1/g' \
| sed 's/\$${[^}]*}//g' \
| sed 's/{{ *[a-z_]* *}}/latest/g' \
| grep -v '^$$' | grep -v '^latest$$' | grep -v 'cmd:' | grep -v '{' \
| sed 's/:vlatest/:latest/g' \
| sort -u > /tmp/images.txt
echo "Found images to scan:"
cat /tmp/images.txt
echo "---"
i=0
failed=0
failed_names=""
while IFS= read -r img; do
i=$$((i+1))
out="trivy-image-$$i.json"
echo "=== [$$i] $$img -> $$out ==="
# Keep going on a bad image so one unpullable reference cannot cost
# the scan of every image after it, but record that it happened.
if ! trivy image --skip-db-update --scanners vuln --timeout 15m --severity CRITICAL,HIGH --ignore-unfixed --format json -o "$$out" "$$img"; then
failed=$$((failed+1))
failed_names="$$failed_names $$img"
fi
done < /tmp/images.txt
echo "--- scanned $$((i - failed)) of $$i images ---"
if [ "$$failed" -gt 0 ]; then
echo "NOT SCANNED:$$failed_names"
exit 1
fi
# Non-zero above turns this step red without failing the workflow, so the
# fix agent still runs on what was scanned. Coverage is reported, not
# enforced: an incomplete scan is visible in the step status instead of
# being indistinguishable from a clean one.
failure: ignore
- name: security-fix-agent
# Auto-fix run: only on main (cron / manual / direct push). On the
# bot's own PR branches, the scans re-run as a re-validation gate but
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
GITEA_TOKEN:
from_secret: security_bot_token
commands:
- |
# Human-readable per-finding summary so the log is useful even
# when the agent step exits early.
echo "=== IaC misconfigurations ==="
jq -r '.Results[]?.Misconfigurations[]? | "\(.Severity)\t\(.ID)\t\(.Title)\t\(.AVDID // "")"' trivy-iac.json 2>/dev/null | sort -u || true
echo "=== Image vulnerabilities ==="
for f in trivy-image-*.json; do
[ -f "$$f" ] || continue
jq -r --arg src "$$f" '.Results[]?.Vulnerabilities[]? | "\($src)\t\(.Severity)\t\(.VulnerabilityID)\t\(.PkgName)\t\(.InstalledVersion) -> \(.FixedVersion // "none")"' "$$f" 2>/dev/null || true
done
- |
# Gate: bail early if the scans found nothing actionable.
IAC_FINDINGS=$(jq '[.Results[]?.Misconfigurations[]? | select(.Severity == "CRITICAL" or .Severity == "HIGH")] | length' trivy-iac.json 2>/dev/null || echo 0)
IMG_FINDINGS=0
for f in trivy-image-*.json; do
[ -f "$$f" ] || continue
n=$$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL" or .Severity == "HIGH")] | length' "$$f" 2>/dev/null || echo 0)
IMG_FINDINGS=$$((IMG_FINDINGS + n))
done
echo "IaC findings: $$IAC_FINDINGS, Image findings: $$IMG_FINDINGS"
if [ "$$IAC_FINDINGS" -eq 0 ] && [ "$$IMG_FINDINGS" -eq 0 ]; then
echo "No CRITICAL/HIGH findings — nothing for the agent to do."
exit 0
fi
- |
# Branch + git identity. The bot user owns the commits; gitea_admin
# is intentionally NOT used here so PRs are visibly machine-authored.
SHORT_SHA=$$(echo "$${CI_COMMIT_SHA:-manual}" | cut -c1-7)
BRANCH="security-fix/$$(date +%Y-%m-%d)-$$SHORT_SHA"
git config user.email "security-bot@renner.uno"
git config user.name "Security Fix Agent"
git checkout -b "$$BRANCH"
echo "$$BRANCH" > /tmp/branch
- |
# Hand off to Claude Code in headless mode. NOTE: --bare is
# NOT passed — bare mode skips CLAUDE_CODE_OAUTH_TOKEN reads
# (per authentication.md), and we authenticate via the
# subscription-backed OAuth token, not an API key. acceptEdits
# auto-approves file edits without an interactive prompt.
#
# Tool allowlist: file ops only. Image-tag bumps are out of
# scope (handled by Flux image-automation), so no Bash needed.
#
# Prompt is piped via stdin instead of -p "$(cat ...)"; the
# latter passes a multi-KB substituted string into the command
# line which busybox ash's tracer (set -x) misparsed under
# woodpecker, producing a spurious "unterminated quoted string".
# 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 "fix CRITICAL/HIGH CVEs and IaC misconfigurations (Trivy findings) in Kubernetes/Ansible manifests by editing files; multi-step, security-sensitive work touching container versions, RBAC and configuration that needs careful, capable reasoning")
if [ -n "$$MODEL" ]; then echo "security-fix: selected model $$MODEL"; else echo "security-fix: no live catalog — using the CLI default model"; fi
cat ci/agents/security-fix/prompt.md | claude $${MODEL:+--model $$MODEL} \
--permission-mode acceptEdits \
--allowedTools "Read,Edit,Write,Glob,Grep" \
-p
- |
# If the agent didn't change any tracked files, there's nothing to
# PR. Trivy JSON outputs are gitignored so they won't show as diff.
if git diff --quiet && git diff --cached --quiet; then
if [ -z "$$(git ls-files --others --exclude-standard)" ]; then
echo "Agent ran but produced no edits — no PR will be opened."
exit 0
fi
fi
- |
BRANCH=$$(cat /tmp/branch)
BODY=$$(cat /tmp/security-fix-summary.md 2>/dev/null || echo "Agent-proposed security fixes (no summary written by the agent).")
TITLE="security: agent-proposed fixes ($$(date +%Y-%m-%d))"
git add -A
git commit -m "$$TITLE"
git push "https://$$GITEA_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
jq -n \
--arg title "$$TITLE" \
--arg body "$$BODY" \
--arg head "$$BRANCH" \
'{title: $$title, body: $$body, head: $$head, base: "main"}' \
| curl -sfL -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data @- \
https://gitea.renner.uno/api/v1/repos/konairius/homelab/pulls \
| jq '{number, html_url, title}'