126 lines
6.7 KiB
YAML
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
|