# 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 ", "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