Files
felhom.eu/.gitea/workflows/security-watch.yml
T

126 lines
6.7 KiB
YAML

# security-watch — every night, read the security notices of everything Felhom ships, and mail the operator
# (admin@felhom.eu, never a household) what affects a pinned version (R-1303). The logic, its classes and its mails:
# scripts/security_watch.py; the list of pins: scripts/security_watch_inventory.py; why these sources:
# documentation/audits/security-watch-2026-10-26/SPIKE.md.
#
# THE MEMORY is documentation/security-watch/state.json on `main`, committed back by this job with its own Actions
# token. Why there and not elsewhere (2026-10-26): the two runners each have their OWN disk (act-runner-data,
# act-runner-2-data — a job lands on either), so a runner file would be two memories; Actions cache needs a node
# `uses:` step, which this python-and-git runner cannot run; anything on the DooPlex host is a host change. A file in
# the repo is one memory, readable by a person, and its history says when each advisory was first mailed.
# If the push of the memory fails, the job fails and the alarm below mails — the next night may then mail an advisory
# twice, which is the safe direction.
#
# Manual run (`workflow_dispatch`, mode):
# run a normal night, now
# seed record every advisory open now as known and mail nothing new (the first run; prints what it WOULD mail)
# dry no mail, no memory change — read the result table in the log
# test-mail send ONE clearly marked test advisory to the operator; touches nothing else
name: security-watch
on:
schedule:
- cron: "40 3 * * *" # 03:40 UTC: after the 03:20 gates run
workflow_dispatch:
inputs:
mode:
description: "run | seed | dry | test-mail"
default: "dry"
jobs:
watch:
runs-on: felhom-gates
timeout-minutes: 20
permissions:
contents: write
steps:
- name: Fetch main (latest, for the memory) and the two sibling repos
run: |
echo "WATCH_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV"
base=http://gitea.gitea-system.svc.cluster.local:3000/admin
git init -q . && git remote add origin $base/felhom.eu.git
git fetch -q --depth 1 origin main && git checkout -q -B main FETCH_HEAD
echo "felhom.eu main at $(git rev-parse --short=12 HEAD)"
for r in felhom-controller app-catalog-felhom.eu; do
git init -q ../$r && git -C ../$r remote add origin $base/$r.git
git -C ../$r fetch -q --depth 1 origin main && git -C ../$r checkout -q FETCH_HEAD
echo "$r at $(git -C ../$r rev-parse --short=12 HEAD)"
done
- name: Run the watch
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
GH_ADVISORY_TOKEN: ${{ secrets.GH_ADVISORY_TOKEN }}
MODE: ${{ github.event.inputs.mode }}
run: |
set -u
mkdir -p documentation/security-watch
mode="${MODE:-run}"
[ "${GITHUB_EVENT_NAME:-}" = "schedule" ] && mode=run
echo "mode: $mode"
case "$mode" in
test-mail) python3 scripts/security_watch.py --test-mail; exit $? ;;
dry) extra="--no-mail" ; state=/tmp/state-dry.json
cp documentation/security-watch/state.json "$state" 2>/dev/null || true ;;
seed) extra="--seed" ; state=documentation/security-watch/state.json ;;
run) extra="" ; state=documentation/security-watch/state.json ;;
*) echo "unknown mode $mode"; exit 2 ;;
esac
python3 scripts/security_watch.py --state="$state" --catalog=../app-catalog-felhom.eu \
--controller=../felhom-controller --json=/tmp/watch-results.json $extra
- name: Commit the memory (only when it changed)
if: github.event.inputs.mode != 'dry' && github.event.inputs.mode != 'test-mail'
env:
PUSH_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -u
f=documentation/security-watch/state.json
if [ ! -f "$f" ]; then
echo "no memory file was written - nothing to commit"; exit 0
fi
if git ls-files --error-unmatch "$f" >/dev/null 2>&1 && git diff --quiet -- "$f"; then
echo "memory unchanged - nothing to commit"; exit 0
fi
cp "$f" /tmp/state.json
auth=$(python3 -c 'import base64,os;print(base64.b64encode(("x:"+os.environ["PUSH_TOKEN"]).encode()).decode())')
for attempt in 1 2 3; do
git fetch -q --depth 1 origin main && git checkout -q -B main FETCH_HEAD
mkdir -p "$(dirname "$f")" && cp /tmp/state.json "$f"
git add "$f"
git -c user.name="felhom security watch" -c user.email=monitoring@felhom.eu \
commit -q -m "security-watch: memory of $(date -u +%F) (R-1303). Docs only." || { echo "nothing to commit"; exit 0; }
if git -c http.extraHeader="Authorization: Basic $auth" push -q origin HEAD:main; then
echo "memory pushed: $(git rev-parse --short=12 HEAD)"; exit 0
fi
echo "push refused (attempt $attempt) - re-fetching main"
sleep 5
done
echo "MEMORY NOT SAVED after 3 attempts"; exit 1
- name: Alarm on failure
if: failure()
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
python3 - <<'PY'
import json, os, sys, time, urllib.request
key = os.environ.get("RESEND_API_KEY", "")
if not key:
sys.exit("ALARM FAILED: RESEND_API_KEY is empty")
run = os.environ.get("GITHUB_RUN_ID", "?")
link = "https://gitea.dooplex.hu/%s/actions/runs/%s" % (os.environ.get("GITHUB_REPOSITORY", "?"), run)
started = os.environ.get("WATCH_STARTED_AT", "")
elapsed = "%d s" % (int(time.time()) - int(started)) if started.isdigit() else "unknown"
print("ALARM-LINK " + link)
body = json.dumps({"from": "Felhom CI <monitoring@felhom.eu>", "to": ["admin@felhom.eu"],
"subject": "[felhom CI] security watch FAILED",
"text": ("The nightly security watch did not finish (R-1303).\n\nRun : %s\nElapsed : %s\n\n"
"Until it runs again, silence from the watch does NOT mean that nothing affects us.\n"
"The run log names the step: the watch itself, a mail it could not send, or its memory "
"it could not save.\n") % (link, elapsed)}).encode()
req = urllib.request.Request("https://api.resend.com/emails", data=body, method="POST", headers={
"Authorization": "Bearer %s" % key, "Content-Type": "application/json", "User-Agent": "felhom-ci/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
print("RESEND-ACCEPTED id=%s" % json.load(r)["id"])
PY