162 lines
8.1 KiB
YAML
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}'
|