Author SHA1 Message Date
Thore Cimbal 937a17ecf8 cve: seventeen decisions pointing at nothing, found the first time the counter could see
The unblinded counter went live with this pull and immediately reported
seventeen. Eleven belong to images the operating-stack deploy replaced — those
decisions did their job and are gone.

The other six are one CVE. Trivy's database moved CVE-2026-57433 in perl-base
from critical to high, and with that a single reclassification made six
decisions across six images obsolete at once. A severity is not a property of
our estate; it belongs to somebody else's database and changes underneath us.
That is now written next to the format description, because the next person will
otherwise wonder why an entry rots without anything here changing.

Fifteen entries and 58 identifiers remain.
2026-08-21 12:00:00 +00:00
Thore Cimbal 9a3b1d1dd5 cve: a successful query can still be worthless, and it cost 42 reports
Restarting k3s took kube-state-metrics and alloy's log tailers down with it, so
kube_pod_container_info went empty in prometheus. The derivation asked, got a
clean response with zero rows, and counted it as success. Cluster targets went
from 39 to none, the total from 54 to 12, and the scan loop deleted every report
whose target had vanished. Coverage then read 1.0.

None of the four rules fired, and each for a defensible reason: the set was not
empty because the operating host still answered, and every timestamp was fresh
because an empty success updates it. The gap sat exactly between them.

A source that has delivered before and now delivers nothing is treated as a
failure: its previous targets are kept, its timestamp ages, and the stale rule
takes over. A fifth rule watches the total for a drop of more than 40 percent,
and it deliberately also fires on a deliberate shrink — losing 40 percent of the
checked estate is worth a line either way.

The six rancher decisions are gone too. They described versions that no longer
run: the k3s patch took all fifteen of their criticals with it.
2026-08-21 12:00:00 +00:00
Thore Cimbal c7aaf388f5 monitoring: the same situation has three different numbers
Grafana 13.2.0 shows 162 findings, 63 series, or 28 vulnerabilities depending on
what is being counted, because the same go stdlib repeats across thirteen plugin
binaries. Prometheus counts series — one per CVE, package and version per target
— so on the dashboard the jump reads 70 to 63, which is fewer, not more.

That makes my original rejection wrong twice over: I compared images with
different contents, and I compared a series count against an occurrence count.
The comment above the version line now carries all three numbers so the next
person does not repeat it, and the README says what a series actually is.
2026-08-21 12:00:00 +00:00
Thore Cimbal d68991629e cve: the stale-decision counter was blind to the case it exists for
I wrote 'and ziel in soll' into that condition this evening, and it means a
decision only counts as stale while its image is still deployed. The moment an
image leaves the inventory — a version bump, which is exactly when decisions go
stale — the entry stops being checked and sits in the file forever. Grafana
12.0.0 and 12.4.9 proved it hours later: both entries survived the jump to
13.2.0 and the counter reported zero.

The cost of removing the guard is that a decision made before the scanner reaches
its image shows up here until the next round. The alert waits 24 hours, which
covers it.

The existing arithmetic test could not have caught this, because it reimplements
the calculation and the faulty condition was never in the copy. New tests drive
the real collect() instead, including one that the old behaviour fails.

Both dead grafana entries removed: 28 decisions down to 26.
2026-08-21 12:00:00 +00:00
Thore Cimbal 3aee13800d monitoring: grafana 13.2.0, loki 3.7.6, alloy v1.18.1
Grafana's core binary is clean at 13.2.0 where 12.4.9 carried the one critical
still on our books. The image also reports far more high findings, and the
comment above the line explains why that is not a reason to go back: 13.x ships
thirteen datasource plugins inside the image, every one of those findings lives
in a plugin binary, and none of the affected datasources is one we use.

Loki drops from 41 high to 8, alloy from 44 to 14. Neither carries state that
has to migrate.

Grafana's does, and it migrates without a way back. sorb accepted that: the
volume holds preferences and history, while both datasource UIDs are pinned in
provisioning and all eleven dashboards come from files — so even losing it
entirely restores to the same UIDs the 657 dashboard references expect.
2026-08-21 12:00:00 +00:00
Thore Cimbal c942ce9328 cve: decide the one critical the new grafana brings with it
12.4.9 is what runs now, and its single remaining critical sits in a bundled go
dependency that grafana has to update, not us. Recording it before the scanner
reaches that image, so the count does not go to one and stay there.

Worth noting why it was not in the earlier list: the old scanner did not know
CVE-2025-41115 in grafana 12.0.0 at all. Trivy 0.74.0 found it on the first
round, on an image we were already replacing — an outdated scanner is outdated
detection, which was the argument for bumping it, now with an example.
2026-08-21 12:00:00 +00:00
Thore Cimbal 2c0caacfb0 cve: the cadvisor decision cited a commit that never touched it
I wrote that v0.55.1 was waiting in 516641b. It is not — cadvisor is not in this
compose file at all; it runs in the portainer stack next door, so nothing here
sets its version. The finding stands and the measurement stands, but the reason
attached to it was false, and a decision is only worth its reason.

Same shape as the portainer agent: measured, outside our deployment path, and
therefore dated with the others rather than with this week's rollout.
2026-08-21 12:00:00 +00:00
Thore Cimbal 1ce4b55b75 monitoring: the hardening lived on the host and not in here
Prometheus and Loki accept writes without authentication and were bound to
0.0.0.0; node-exporter likewise. That was fixed on CFGMON during the firewall
work today and never came back to the repository, so this file still described
three open ports and a firewall as the only thing in front of them.

The consequence is worse than a stale comment. A pull would have reverted the
binding and reopened all three, and nothing here or there would have said so.
It did not happen only because git refused the pull over the local edit — the
accident that saved it is not a control.

Ports now match what actually runs: the private vSwitch address for the hosts
that push, localhost for the host itself, and node-exporter on localhost alone.
2026-08-21 12:00:00 +00:00
Thore Cimbal 422b651f43 cve: synapse carries six of its own, and they were nearly missed
The pipeline still holds the report for v1.151, so the six criticals in v1.158
never showed up in any query against it. They surfaced only from re-deriving the
target set against the live cluster and folding in the pre-deploy scans by hand.
Five sit without a fix in the debian base, one in the bundled tool's go runtime;
the chart sets the version, so there is nothing to take.

With that entry the count over all 54 desired targets is zero open.
2026-08-21 12:00:00 +00:00
Thore Cimbal d5e2995314 cve: a digest-pinned container was about to fall out of the target set
The alloy chart's 1.x line pins its config-reloader sidecar by digest. Kubernetes
then reports that container's image as a bare sha256 and puts the usable
reference in image_spec alone, which the derivation was not reading. An hour
after the chart bump the sidecar would have gone unscanned — the exact hole
#0106 exists to close, reopened by an upgrade rather than by neglect.

Worse than missing: the bare digest passed normalisation as repository 'sha256'
with the hex as its tag, so it would have entered targets.txt, failed every pull,
and shown up as a permanent coverage gap pointing at nothing.

Both ends are closed and both are asserted, including that the query still asks
for image_spec — an assertion on the parsing alone would stay green while the
data never arrives.

Ten more decisions cover what is fixed in the repo but not yet rolled out on the
operating host, dated a week out so the alert speaks up if the deploy does not
happen.
2026-08-21 12:00:00 +00:00
Thore Cimbal 44cc030f0b cve: a critical with nothing left to do is allowed, but only on the record
Sixty-three criticals on running images have no fix to take. Writing them into
a trivy ignore file would have been the obvious move and the wrong one: trivy
drops ignored findings from its output, so afterwards 'zero because fixed' and
'zero because we looked away' render identically. Every finding stays in
trivy_vuln_info. The decisions sit beside them in entscheidungen.json and are
counted, not subtracted.

Each entry names its CVEs one by one. A blanket entry per image would also
swallow the next finding that shows up there, which is the finding you would
most want to see. The loader rejects an entry without ids, and rejects a review
date it cannot parse — rejecting the whole file, because a half-read decision
list is worse than none.

An unreadable file leaves everything counted as open. Getting that direction
backwards would mean a typo reads as 'all decided', and nobody would notice.
The end-to-end run caught the same mistake in the other half: when the derived
target set is empty the set is unknown, not empty, so the open count now falls
back to every report rather than to zero.

Three python services move off the debian base while we are here — 3.13-slim
carried four criticals with no fix, 3.13-alpine none. All three run on the
stdlib alone and TLS was checked inside the image before the switch.
2026-08-21 12:00:00 +00:00
Thore Cimbal 516641bbba monitoring: five image versions, measured before choosing (#0051)
The operating stack carried 30 critical and 676 high findings. These five lines
remove fourteen and 278 of them. Every target was scanned before it was written
into the file:

  trivy          0.58.2  -> 0.74.0    critical 4 -> 0, high 98 -> 0
  grafana        12.0.0  -> 12.4.9    critical 7 -> 1, high 70 -> 3
  prometheus     v3.3.1  -> v3.14.0   critical 2 -> 0, high 46 -> 2
  alertmanager   v0.28.1 -> v0.34.0   critical 1 -> 0, high 41 -> 2
  node-exporter  v1.9.1  -> v1.12.1   critical 1 -> 0, high 38 -> 8

Three candidates were measured and rejected, which is the point of measuring.
Grafana 13.2.0 clears all seven critical findings but takes high from 70 to 162,
so the minor jump inside 12.x beats the major one by a wide margin. cadvisor only
goes from five critical to four. And python:3.13-slim is unchanged — the host
already holds the current build, so a repull buys nothing.

Configuration was validated against the new tools rather than the old ones:
promtool v3.14.0 accepts prometheus.yml with both rule files, all 20 plus 5
rules, and the rule unit tests; amtool v0.34.0 accepts alertmanager.yml.

⚠️ Needs a deploy on the operating host. Recreate rather than up: the images
change, and the CVE targets themselves move with trivy and the exporter.
2026-08-21 12:00:00 +00:00
Thore Cimbal 59c75c8a01 cve: actually test the narrowing — the previous commit shipped it uncovered
Disabling the filter left all twenty-two assertions green, which means nothing
tested it. The counter-case I had written exercises the skip in the derivation,
not the filter inside the registry selection, so the feature went out with no
coverage at all — the project's own favourite failure, in the change that was
meant to remove noise.

Three assertions now cover it: three repositories of which one runs, the
counter-case showing the same call returns all three without estate knowledge,
and one that pins what the rule must not do — it excludes repositories, not old
tags, so a rollback target of a running service still counts.

Same sabotage as before now turns one of them red.
2026-08-21 12:00:00 +00:00
Thore Cimbal 4758e35150 cve: only scan registry repositories that are actually running (#0051)
Decision by sorb: narrow the target set to what is operated rather than tidying
the registry. A repository now counts only while at least one of its tags is
running, which drops element-desktop-build and windows-vm and 61 of their 62
critical findings with them. Of those 62, exactly two had a fix available; they
are build artefacts nobody runs, so remediation was never the right answer.

The narrowing stays derived rather than maintained: the running set is the one
the derivation already builds, so there is no second list to keep in step
(ADR-0026).

Order now matters. The registry selection needs the running estate to tell a
rollback target from a build artefact, so it runs after the other two sources
and is skipped when they yield nothing.

That last part changes behaviour deliberately. A derivation where both estate
sources answer successfully but empty used to count as complete and merely
unusable; it now reports the registry as failed. If nothing is running at all,
that is an outage rather than a normal state, and it should say so instead of
hanging on a single boolean. The test carries the new contract with that
reasoning written next to it, plus a counter-case proving the registry does run
when the estate is known.
2026-08-21 12:00:00 +00:00
Thore Cimbal c5b20a1e47 docs: the README told the reader to maintain a file that no longer exists
Three passages still described cve/images.txt, and one of them instructed the
next person to keep it in step with the stack on every change — the exact habit
ADR-0026 abolished, and the one that had left coverage at 52 percent.

The section now says there is nothing to keep in step, names the three sources
the set is derived from, and carries the warning that matters: a failed
derivation shrinks the desired set, which makes coverage look better rather than
worse. Whoever edits this must not trim the freshness stamps away.

The compose comment said the same thing and is corrected with it.
2026-08-21 12:00:00 +00:00
Thore Cimbal 07875ba6bb cve: a target that leaves the set loses its report (#0106)
Without this, an image dropped from the desired set keeps reporting: the
exporter reads every json in the results directory and takes the target from
Trivy's own ArtifactName. That is precisely what the security room showed on
2026-08-20, when it carried HIGH findings for threadnet-web:v0.3.0, an image
that runs nowhere.

Deletion only ever runs against a list that was successfully read. The guard at
the top of the round already skips everything when the list is missing or
empty, so a restart during a Prometheus outage cannot clear the estate.

The round became a function so the test can load the real one. The first version
of that test rebuilt the loop instead, and a rebuilt test proves the rebuild —
it stayed green while the shipped file set its paths unconditionally and ignored
the environment entirely. Loading it exposed that within one run.

Two of my own errors are fixed here as well. The driver read
`runde || sleep A && sleep B`, which groups left to right, so a missing list
would have slept the wait AND the full day — exactly what the short wait exists
to prevent. And the paths were hardcoded where the exporter already took them
from the environment.

Removing the guard as a deliberate sabotage turns the dangerous case red:
untouched reports drop from two to zero.
2026-08-21 12:00:00 +00:00
Thore Cimbal 386b65a9e0 cve: the scanner reads the derived set, images.txt is gone (#0106)
Criterion one of this piece of work was that the hand-maintained list stops
existing and is not replaced by another maintained file. It is deleted here; the
scanner reads what the exporter derives.

Without a usable list the loop does nothing at all and retries in a minute
rather than sleeping out the full day, because after a deploy the exporter only
writes the file on its next scrape. The old file staying put during a failed
derivation is deliberate: the loop then keeps working from the last known state
instead of running into nothing.

Probed rather than assumed, with trivy stubbed: no list skips the round, an
empty list skips the round, and a list with a comment header scans exactly its
two entries.
2026-08-21 12:00:00 +00:00
Thore Cimbal b989987d69 cve: four rules and three panels guard the derivation (#0106)
Gate 3 planned three rules; there are four. The fourth covers a case the others
miss entirely: every source answers cleanly but empty. Then nothing is missing,
because the desired set is empty, the freshness stamps are current, and nothing
is scanned at all. The Python suite already carries that case as "an empty set
is not the same as success", so the rule belongs with it.

These are the first rule unit tests in this stack. Each rule has a case where it
must fire and one where it must stay silent, because a rule that always fires
cannot be told from a correct one otherwise. Two sabotages confirm the tests
bite: an unreachable threshold on the source-freshness rule makes the expected
alert vanish, and removing the six hour grace period makes the missing-targets
rule fire at five hours where the test demands silence.

The grace period is not padding. A full round over roughly 65 images takes time,
so right after a deploy the gap is real rather than wrong.

The dashboard gains coverage and unscanned-image counters in the two free slots
of the top row, and a source-freshness bar at the bottom, so no existing panel
moves. That bar is the only place where a failed derivation can be told apart
from success.
2026-08-21 12:00:00 +00:00
Thore Cimbal 27770b6ec7 cve: derive the target set and measure coverage — observing only (#0106)
The scan loop is untouched and still reads images.txt, so nothing about this
deployment behaves differently. What changes is that the exporter now knows what
*should* be scanned and can say how much of it is: three sources, none of them
new infrastructure. The cluster and the operating host both already sit in the
same Prometheus, and the registry answers an anonymous token — the same token
dance Trivy performs to pull.

The coverage numbers are the point of this slice. Once deployed they have to
read 24 missing, 2 orphaned and about 0.52, because that is what was counted by
hand on 2026-08-21. A different answer means the derivation is wrong, not the
hand count stale.

Failure handling is the substance rather than an afterthought. A source that
fails costs only its own share; its freshness timestamp keeps ageing instead of
disappearing, because a series that vanishes can never fire a rule — the third
finding of the 2026-08-01 AAR. When every source fails the target file is left
untouched, so a restart during a Prometheus outage cannot clear the estate.

Twenty-one assertions cover it, each paired with its counter-proof: names that
must collapse and names that must not, a source filter that is shown to matter
by removing it, and time-ordered tag selection against the name ordering that
would silently drop v0.10.0. Sabotaging the normalisation turns eleven of them
red, so the suite demonstrably can fail.
2026-08-21 12:00:00 +00:00
Thore CimbalandClaude Opus 5 2eec485bef alerts: the silence alarms now cover the media restore probe too
A second monthly probe exists (restore-drill-media). Its failure was
already covered - BackupJobFailed matches the prefix - but its silence was
not, and silence is the failure this rule set was written against.

RestoreDrillStale now matches the prefix instead of one exact name, and
names the affected probe in the message: with two drills, "the restore
probe has not run" no longer says which. BackupCronJobMissing gains the new
cronjob, because a series that disappears is not an alert in Prometheus,
it is quiet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2Q4Ri8NGwyTZzScvKnWFM
2026-08-21 12:00:00 +00:00
Thore Cimbal 7b25b6e5ae monitoring: die letzten fuenf Einzeldatei-Mounts auf Verzeichnisse (#0083)
Docker haengt Einzeldatei-Mounts am Inode auf; git pull ersetzt die Datei per
Rename, der Container zeigt weiter auf die alte. Fuer prometheus, alertmanager,
cve-scan und grafana war das laengst behoben - fuer loki, alloy und die drei
Python-Skripte nicht.

Bei den Skripten war die Falle sogar schlimmer: Python liest beim Start, ein
Neustart ist ohnehin noetig und wird deshalb fuer ausreichend gehalten. Der
Prozess startet neu, der Mount zeigt weiter auf den alten Inode, der Container
meldet Erfolg und faehrt alten Code.

Geaendert:
  loki           ./loki:/etc/loki           (-config.file zieht mit)
  alloy          ./alloy:/etc/alloy         (Pfad bleibt gleich)
  matrix-alerts  ./alertmanager:/app
  release-watch  ./alertmanager:/app
  cve-exporter   ./cve:/app

Belegt statt angenommen: docker compose config gueltig; alle vier Pfade in
Wegwerf-Containern nachgesehen; und die Gegenprobe - Datei im laufenden
Container geaendert, Pruefsumme wechselt. Genau das ging vorher nicht.

⚠️ Einmalig noetig: docker compose up -d --force-recreate loki alloy
matrix-alerts release-watch cve-exporter. Danach nie wieder.
2026-08-20 12:00:00 +00:00
Thore Cimbal fa26393649 monitoring: Gameserver-Dashboard neu bauen (#0002)
Die bisherige Fassung fragte elf Metriken des loens2-Exporters ab
(pterodactyl_server_cpu_absolute, _memory_mebibyte, _uptime_milliseconds ...).
Keine davon existiert - der Exporter hat nie funktioniert, das Dashboard war
seit jeher leer, und es ist niemandem aufgefallen.

Neu auf dem, was tatsaechlich anliegt: fuenf pterodactyl_server_*-Metriken des
Exporters, der die Panel-Datenbank liest, plus die fuenf gameserver:*-Recording-
Rules fuer CPU, Speicher und Netz.

Zwei Fallen in den Daten, beide beruecksichtigt:

- status, players und max_players tragen KEIN uuid und KEIN game, nur server_id
  und server_name. Die Zusatzangaben in der Tabelle kommen deshalb ueber einen
  Join auf pterodactyl_server_info.
- players liefert NaN, wenn der Server steht. Ohne den Vergleich > -1 zoege ein
  einziger gestoppter Server die Gesamtsumme auf NaN.

Alle elf Queries vor dem Commit gegen den laufenden Prometheus geprueft, nicht
nur geschrieben - genau der Fehler, an dem die alte Fassung starb.
2026-08-20 12:00:00 +00:00
Thore Cimbal a07c6eda09 monitoring: Game-Host-Anbindung nach der Gegen-Uebergabe fertigstellen
Die Host-Seite ist deployt und verifiziert; der Betreiber hat drei Abweichungen
zu ba59518 gemeldet, alle uebernommen:

1. pterodactyl_exporter zeigt auf 9810 statt 9531. Der alte Exporter (loens2)
   hat NIE funktioniert - er rief die Client-API mit einem Application-Key ueber
   http auf und lieferte null pterodactyl_*-Metriken. Ersetzt durch einen, der
   die Panel-MariaDB read-only liest und keinen API-Key braucht.

2. metric_relabel_configs sind hinzugekommen. Sie lebten bisher im lokalen
   Prometheus des Game-Hosts und gehoeren immer dem SCRAPENDEN Prometheus - mit
   dessen Rueckbau also hierher. Ohne sie waeren Gameserver nur unter ihrer UUID
   sichtbar und je Serie rund 20 container_label_* uebrig geblieben; erwartete
   Serienzahl faellt von 3615 auf ~1700.

3. instance = gameserver statt pterodactyl, damit Metrik und Log denselben
   Schluessel tragen (promtail setzt host=gameserver). Kostet keine Historie -
   die Targets sind am 2026-08-20 zum ersten Mal ueberhaupt up gegangen.

Neu: gameserver-rules.yml nimmt den Join Container-UUID -> Klarname vorweg.
cAdvisor kennt Gameserver nur unter ihrer UUID, und Relabeling kann keine
zweite Metrik nachschlagen.

Mit promtool gegen die hier tatsaechlich laufende Version 3.3.1 geprueft (die
Uebergabe nutzte 3.0.0): Konfiguration gueltig, 2 Regeldateien, 5 Regeln.

⚠️ Die Uebergabe nennt --force-recreate wegen der Inode-Falle (#0083). Fuer
Prometheus gilt das hier NICHT mehr: Das Verzeichnis wird gemountet
(./prometheus:/etc/prometheus), genau um diese Falle zu beseitigen. Ein
restart bzw. SIGHUP genuegt.
2026-08-20 12:00:00 +00:00
Thore Cimbal ba59518ef0 monitoring: Game-Host privat scrapen statt ueber die oeffentliche IP (#0002)
Die Targets standen seit dem ersten Scrape auf der oeffentlichen IP und waren
nie up. Ursache jetzt belegt: Die Exporter-Container auf dem Game-Host
veroeffentlichen ihre Ports gar nicht - docker ps zeigt "9100/tcp" ohne
0.0.0.0-Mapping, sie sind nur im Docker-Netz erreichbar. Weder Firewall noch
Bind-Adresse, wie bisher vermutet.

Targets auf 10.0.0.4 umgestellt (Host liegt im vSwitch, 22/80/443 antworten
dort). cadvisor bewusst auf Host-Port 8081: 8080 ist von coolify-proxy belegt.

Neu angebunden: pterodactyl_exporter auf 9531 - laeuft dort seit drei Monaten
und war nie im Monitoring.

README nachgezogen; sie nannte weiterhin die oeffentliche IP und beschrieb die
Targets als "antworten aktuell nicht", ohne den Grund.

⚠️ Wirksam erst, wenn die Ports auf dem Game-Host auf 10.0.0.4 veroeffentlicht
sind. Bis dahin bleiben die Targets down - unveraendert zum Zustand davor, nur
mit richtiger Adresse und dokumentierter Ursache.
2026-08-20 12:00:00 +00:00
Thore Cimbal 7ee42f90e6 monitoring: Dashboard fuer blockierte ClamAV-Inhalte (management #0077)
Sichtbar machen, wie oft ClamAV tatsaechlich etwas blockiert - bisher nur in
Logzeilen zu finden.

Zwei Abweichungen vom Issue-Text, beide gemessen statt angenommen:

1. Das Issue schlaegt {app="synapse-main"} vor. Das Label app existiert in
   dieser Loki nicht - Cluster-Logs liegen unter
   job="loki.source.kubernetes.k8s_logs" mit instance="<ns>/<pod>:<container>".
   Die vorgeschlagene Query haette dauerhaft ein leeres Panel ergeben.

2. Das Issue kennt nur das Synapse-Modul. Seit dem Client-seitigen Scannen gibt
   es eine zweite Quelle (clamav-http-scanner), und die ist die wichtigere: Sie
   greift beim Senden UND beim Empfangen und deckt damit auch verschluesselte
   Raeume ab. Das Synapse-Modul sieht nur unverschluesselte Uploads - deshalb
   steht es dort auf null, waehrend der Client sieben Treffer zeigt.

Drittes Panel zeigt Fail-open-Faelle: Ist clamd nicht erreichbar, laesst der
Scanner Dateien bewusst durch. Ohne dieses Panel bliebe genau das unsichtbar -
rot ab dem ersten Fall.

Alle acht Queries gegen die laufende Loki geprueft, nicht nur geschrieben.
2026-08-20 12:00:00 +00:00
Thore CimbalandClaude Opus 5 9a106151f1 monitoring: a read error must not erase the CVE timeline (management #0082)
The exporter pruned first_seen on every scrape, keeping only what it had just
seen. A report that failed to parse - a file being written, a brief I/O error -
was skipped by a silent `continue`, so its findings never entered seen_keys and
their first-seen timestamps were deleted for good. Nothing reported it, and
"first seen" simply restarted at now.

Pruning is now limited to targets whose report was actually read this round.
Proven both ways against a throwaway results directory rather than by reasoning:
make one report unreadable and its entry survives while read_errors counts 1; fix
the other report but drop its finding and that entry is pruned as before. The
distinction is the point - the old behaviour was not too aggressive, it was
indiscriminate.

Two numbers now leave the exporter: trivy_reports_total and
trivy_report_read_errors, with alerts on both. They cover what TrivyScanStale
cannot reach by construction - a target that never produced a report has no series
for time() to compare against, so it stays quiet no matter how long it has been
broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 be6b5f0657 docs: add group-rules pointer (CLAUDE.md) and repo-specific AGENTS.md
Field test F-011 found four of five components carried no pointer file, so a
session landing here had no path to the group rules at all. CLAUDE.md is the
one-line pointer the check looks for; AGENTS.md links the canonical rules in the
management repo (with the Gitea mirror URL for readers outside the lab) and
otherwise carries only what is specific and easy to get wrong here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00
sorbandClaude Opus 4.8 e9c13dcf22 feat(monitoring): scrape alertmanager, alert on failed delivery; fix stale README
Alertmanager was configured as an alerting target but never scraped, so its own
metrics were absent: a silently breaking alert chain could not report itself —
the same blind spot as a missing series, now at the end of the chain. Adds the
operating_alertmanager scrape job and AlertDeliveryFailing on
alertmanager_notifications_failed_total.

The README still claimed alert delivery was deliberately muted via a
room=security null receiver. That route is gone; alertmanager.yml routes
everything to the matrix receiver, so the backup alerts added yesterday do get
delivered. Documentation asserting the opposite is dangerous in both directions,
so it now states the current wiring and keeps the alert-storm history as
background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00
sorbandClaude Opus 4.8 4cb9bfb2bc fix(monitoring): mount prometheus/alertmanager config dirs, not single files
Deploying e0808ba surfaced the trap in practice: docker pins a single-file bind
mount to the inode, git pull replaces files by rename, so the container kept
serving the old alerts.yml while SIGHUP reported a successful reload — host and
container md5 differed and BackupCronJobMissing simply was not there.

The trap was already documented in this README, in detail, with the correct
command and a verification snippet, and it still bit. A footgun you avoid only by
reading gets stepped on eventually, so remove it structurally: directory mounts
resolve through the path on every access. Config paths are unchanged, so
--config.file keeps working. Remaining single-file mounts (loki, alloy, the
scripts) are named in the README as still needing --force-recreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00
sorbandClaude Opus 4.8 e0808bad90 fix(alerts): a missing series must not silence the backup alerts
Verification on CFGMON showed RestoreDrillStale could never fire: restore-drill
has no last_schedule_time series until its first scheduled run (a manually
triggered job does not set it), and an expression over a missing series yields
nothing. BackupNotRunning shares the flaw — deleting a CronJob removes the very
series the alert reads, so it goes quiet instead of firing.

Fall back to kube_cronjob_created, but aggregate with max by(namespace, cronjob):
'or' matches including __name__, so a bare fallback would return BOTH series and
the never-updating created timestamp would fire permanently once the window
elapsed. Add BackupCronJobMissing so a vanished CronJob is itself the alert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00
sorbandClaude Opus 4.8 1bbff5e749 feat(alerts): alert on backup failure, stalled backups and stale restore drill
There was no rule covering backups at all: a failed nightly job would have gone
unnoticed, which is precisely the silent failure management #0030 is about.
BackupJobFailed catches a failed run, BackupNotRunning catches a CronJob that
stopped scheduling, and RestoreDrillStale fires when the monthly drill stops —
an unverified backup is an assumption again, so the absence of the check is
itself worth alerting on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00
Thore CimbalandClaude Fable 5 32f89afebf BACKLOG.md durch ein README ersetzt
Das Repo hatte auf oberster Ebene kein README - das einzige Frontdoor-Dokument
war ein BACKLOG.md, das seit 2026-07-30 nur noch auf 'sorb/Backlogs' verwies.
Dieses Repo gibt es unter dem Namen nicht mehr (umbenannt zu 'management',
kanonisch auf git.lab statt Gitea), und ein dateibasiertes Backlog widerspricht
ADR-0005: alles Offene ist ein Issue. Der alte Gitea-Link funktioniert nur noch
ueber einen 301-Redirect.

Ein Wegweiser, der auf ein Modell zeigt, das abgeloest wurde, ist schlechter als
keiner - deshalb ersetzt statt geflickt. Das neue README beschreibt, was das
Repo ist, verweist auf monitoring/README.md als Betriebsanleitung und nennt die
einschlaegigen Issues mit ihren aktuellen Nummern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 14:46:04 +02:00
Thore CimbalandClaude Fable 5 ff87cb25fb monitoring: CVE-Alarme aggregiert pro Image + Receiver-Robustheit (gitops#51)
Entscheidung sorb 2026-08-01 (Option 1 aus #51): Alarme als
count by (target, severity) statt pro CVE (~58 Serien statt ~1200),
CVE-Details bleiben im Dashboard (trivy_vuln_info unveraendert).
Receiver: inkrementelles save_state nach jedem Alarm, 1s-Sende-Drossel
(Synapse rc_message), recent_resolved-Dedup gegen doppelte Fallback-Haken
bei Batch-Retries, Teilfehler -> 502 liefert nur den Rest nach.
Stumm-Route + Null-Receiver entfernt - Zustellung wieder scharf.
promtool/amtool/py_compile gruen. UNGETESTET bis Deploy (Uebergabe-Issue).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 16:11:25 +02:00
0bd77e28d8 monitoring: CVE-Alarme vorerst stumm, Deploy-Fallstricke dokumentiert (gitops#47)
Die CVE-Pipeline aus #47 ist deployt und sammelt Daten, die Alarm-Zustellung
ist aber bewusst abgeklemmt: room="security" routet auf einen Null-Receiver.

Grund: die Regeln erzeugen eine Alarm-Instanz pro CVE pro Image (bei 14 von 29
gescannten Images bereits 59 CRITICAL / 445 HIGH). group_by legt alle in eine
Gruppe, matrix-alerts.py schickt eine Nachricht pro Alarm -> Schwall. Dazu
steht save_state() hinter der Sende-Schleife: bricht ein Send ab (Synapse
rate-limitet nach ~10 mit 429), wird kein State gespeichert, der Receiver
antwortet 502 und Alertmanager wiederholt die ganze Gruppe -- mit leerer
Deduplizierung. Details und Weg zum Scharfschalten im README.

Ausserdem dokumentiert: Config-Aenderungen an prometheus.yml/alerts.yml/
alertmanager.yml werden von "docker compose up -d" NICHT aktiv. Die Dateien
sind einzeln gemountet, Docker haengt den Mount am Inode, git pull erzeugt
beim Umbenennen einen neuen -- der Container sieht weiter die alte Datei.
Es braucht --force-recreate; ein SIGHUP laedt nur den alten Inhalt erneut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:09:37 +02:00
Thore CimbalandClaude Fable 5 cdfadc0f26 monitoring: Security-Dashboard-Ordner provisioniert (gitops#47)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 13:42:32 +02:00
Thore CimbalandClaude Fable 5 b6007c50fd monitoring: CVE-Pipeline v1 (gitops#47) - Scanner, Exporter, Regeln, Routing, Dashboard
- cve-scan: Trivy-Loop ueber die 29 real deployten Images (Cluster-Inventur
  2026-08-01 + Prod-Web-Image); 24h-Intervall, Fehler einzelner Images
  blockieren nicht
- cve-exporter: Stdlib-Exporter mit first_seen-State (Zeitstrahl), Schema
  trivy_vuln_info/_count/_first_seen/_last_scan gemaess Pflichtfeldern
- 3 Alertregeln (CRITICAL sofort, HIGH mit 24h-Daempfung, Scan-Frische) -
  promtool SUCCESS 9 rules; alle mit room=security
- matrix-alerts: Label-basiertes Raum-Routing (MATRIX_ROOM_<NAME>), Edits
  landen im richtigen Raum via State
- Grafana-Dashboard cve-overview: Severity-Stats, CVE-Tabelle mit
  NVD-Link/Fix-Version/first-seen, Zeitstrahl, Verlauf
UNGETESTET bis zum Deploy auf CFGMON (compose up -d).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 13:42:07 +02:00
Thore CimbalandClaude Fable 5 f45e01219b monitoring: release-watch in eigenen CVE-/Release-Raum (gitops#47), SBOM-Abgrenzung dokumentiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 10:10:16 +02:00
Thore CimbalandClaude Fable 5 8c06329e33 monitoring: Release-/Advisory-Watch fuer Element-Upstreams (gitops#22)
Stdlib-Daemon im matrix-alerts-Muster: pollt die GitHub-Release-Atom-Feeds
von synapse/ess-helm/element-web/mas/element-call alle 6h und meldet neue
Eintraege als Notiz in den Alerts-Raum (Security-Verdacht mit 🚨 markiert).
Erstlauf setzt nur den State. UNGETESTET bis zum Deploy auf CFGMON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 04:51:53 +02:00
6ffab68583 monitoring: State von matrix-alerts persistent machen
Der Receiver (ea33c3b) merkt sich die Firing-Nachricht pro Alarm, um sie beim
Resolved per Edit abzuhaken. Die State-Datei lag aber unter /tmp im Writable
Layer des Containers. Das ueberlebt ein "compose restart", nicht aber ein
"up -d", das den Container neu baut -- also genau jeden Deploy. Danach haetten
alle offenen Alarme ihre Event-Zuordnung verloren und sich ueber den Fallback
als separate Nachricht aufgeloest, statt die urspruengliche abzuhaken.

Jetzt: named volume matrix_alerts_data auf /state, Pfad per MATRIX_STATE_FILE.

Verifiziert: Testalarm eingekippt, State geschrieben, Container per
--force-recreate neu gebaut (Container-ID 7e013a99d892 -> b1f95380dede),
State unveraendert vorhanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 04:51:08 +02:00
Thore CimbalandClaude Fable 5 ea33c3b7b0 monitoring: Resolved hakt die Firing-Nachricht per Edit ab statt neu zu posten
Ein Alarm = eine Nachricht: Firing-Event-IDs werden je
Alertmanager-Fingerprint gemerkt (State-Datei, ueberlebt Restarts),
Resolved ersetzt die Originalnachricht per m.replace mit
durchgestrichenem Text + Haken. Ohne bekannte Zuordnung Fallback auf
eigenstaendige Resolved-Nachricht. Re-Notifies desselben Alarms
erzeugen keine Doppelnachrichten mehr.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 03:24:52 +02:00
Thore CimbalandClaude Fable 5 5b09a12287 monitoring: echte Alerts-Raum-ID eingetragen, Bot eingerichtet (gitops#32)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-07-31 23:43:04 +02:00
Thore CimbalandClaude Fable 5 682adbdd54 monitoring: Alert-Zustellung auf eigenen @alerts-Bot + dedizierten Raum umgestellt
Entscheidung 2026-07-31 (gitops#32): eigener Bot statt maintenance-notify
(Token-Trennung MATRIX-Host vs. CFGMON), eigener Alerts-Raum im
Operating-Space statt Wartungsraum. Bot-Account existiert bereits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-07-31 23:26:32 +02:00
Thore CimbalandClaude Fable 5 9594ec80ec monitoring: Alerting vorbereitet - Alertmanager, 6 Alert-Regeln, Matrix-Receiver (gitops#32)
Regeln decken die real erlebten Fehlerklassen ab: TargetDown (coturn-Klasse),
KubePodRestartLoop (36k-Restarts-Klasse), OOM-Kills, RAM-/Swap-/Disk-Druck.
Zustellung in den wartung-Raum ueber einen minimalen Stdlib-Webhook-Receiver
(gleiche Machart wie maintenance-notify, gitops Issue #24); Bot-Token kommt
beim Deploy per .env (Vorlage in .env.example).

UNGETESTET/DEPLOY-PENDING: promtool/amtool-Lint auf dem Mac an haengendem
Docker-Hub-Pull gescheitert - vor dem Deploy auf CFGMON ausfuehren (Images
liegen dort bereits):
  docker run --rm -v $PWD/monitoring/prometheus:/cfg:ro --entrypoint promtool prom/prometheus:v3.3.1 check rules /cfg/alerts.yml
  docker run --rm -v $PWD/monitoring/alertmanager:/cfg:ro --entrypoint amtool prom/alertmanager:v0.28.1 check-config /cfg/alertmanager.yml

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:30:40 +02:00
Claude b067c93677 BACKLOG.md: auf zentrales Backlogs-Repo verweisen
Die offenen Punkte betreffen inzwischen mehrere Hosts, dieses Repo
beschreibt aber einen Stack auf einem Host. Inhalte sind nach
sorb/Backlogs umgezogen und dort pro Host strukturiert; hier bleibt nur
der Verweis, damit die Liste nicht an zwei Stellen auseinanderlaeuft.

Die Historie der urspruenglichen Eintraege bleibt bis 25bb5dd erhalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:37:38 +02:00
Claude 25bb5dd44e BACKLOG.md: DNS-Bereinigung bei IONOS aufnehmen
IONOS legt pro Subdomain automatisch einen Mail-Satz (MX, SPF,
DKIM-CNAMEs, autodiscover) und ein www.-Paar an, auch fuer Hosts ohne
Mail. Betrifft selendis und matrix vollstaendig, rohana und game nur
beim www.-Paar.

Ersatzloses Loeschen waere schlechter: ohne SPF gibt es keine Aussage
mehr, und ohne MX weichen Absender per RFC 5321 auf A/AAAA aus -- Mail
an @rohana.axion1337.de landete dann auf Port 25 des Hosts. Richtig ist
Null-MX (RFC 7505) plus SPF -all plus DMARC p=reject.

Konkrete Record-Listen fuer rohana und selendis dokumentiert; der User
setzt diese beiden direkt um. game, matrix, ftp und die Apex-DMARC-
Policy bleiben offen -- bei matrix erst klaeren, ob der Server Mail mit
Absender @matrix.axion1337.de verschickt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:25:13 +02:00
Claude adb8cf3a11 BACKLOG.md: IPv6 und www.rohana ergaenzen
rohana, www.rohana und selendis haben jetzt AAAA-Records auf
2a01:4f8:c17:93eb::1. Let's Encrypt validiert damit bevorzugt ueber
IPv6, die Hetzner-Firewall braucht fuer 443 also eine Regel mit Quelle
::/0 zusaetzlich zu 0.0.0.0/0 -- sonst scheitert die Erneuerung Ende
September trotz offenem IPv4.

Ausliefern ueber IPv6 funktioniert bereits (rohana und selendis
antworten mit 200, Traefik lauscht auf [::]:443).

www.rohana matcht keinen Traefik-Router und liefert das Traefik-Default-
Cert aus. Bei einer Subdomain ist das www.-Praefix ueberfluessig;
Empfehlung ist Loeschen der beiden Records statt Router + Cert-SAN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:17:17 +02:00
Claude 8c7a57dbf1 BACKLOG.md: offene Punkte festhalten
Zwei Punkte, die bewusst nicht sofort erledigt wurden:

1. Cert-Erneuerung ab Ende September 2026 braucht Port 443 aus dem
   offenen Internet (TLS-ALPN-01). Betrifft auch Gitea. Alternative:
   Umstellung auf DNS-01, dann ohne offenen Port.
2. Pterodactyl-Host 157.90.155.206 ist nicht erreichbar (kein Ping,
   Ports 8080/9100 dicht), daher 2 Prometheus-Targets down. Bestand
   schon vor dem Rework.

Zusaetzlich notiert: oeffentlich erreichbarer Remote-Write-Receiver auf
9090 ohne Auth, und dass die Grafana-Admin-Credentials aus .env nicht
fuer die HTTP-API gelten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:11:38 +02:00
Claude edac97e931 monitoring: Alloy-Storage persistieren
--storage.path=/var/lib/alloy/data war gesetzt, aber ohne Volume: die
Positions-Datei lag im Container-Layer und war bei jedem Recreate weg.
Alloy las danach alle Docker-Logdateien von vorn, worauf Loki alle
Eintraege aelter als 7 Tage mit HTTP 400 abwies
("timestamp too old", reject_old_samples). Sichtbar als Fehler-Burst bei
jedem Deploy; betroffen waren nur Alt-Logzeilen bis zurueck zu 2025,
keine aktuellen Daten.

Verifiziert: Positions-Datei liegt jetzt in monitoring_alloy_data und
ueberlebt --force-recreate (28 -> 31 Zeilen), zweiter Recreate erzeugt
0 Fehler. 7 Container liefern weiterhin Logs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:10:04 +02:00
Claude a400f8a4ba monitoring: Grafana-Certresolver auf letsencrypt korrigieren
Das Label nannte den Resolver "le", Traefik kennt ihn aber als
"letsencrypt" (--certificatesresolvers.letsencrypt.acme.*). Traefik
protokollierte daher "Router uses a nonexistent certificate resolver"
und lieferte fuer selendis.axion1337.de sein Default-Self-Signed-Cert
aus. Der Fehler stammt aus dem Altbestand in /opt/monitoring und wurde
bei der Bestandsaufnahme unveraendert uebernommen.

Verifiziert: Let's Encrypt-Cert (YR2) ausgestellt, gueltig bis
2026-10-28, https://selendis.axion1337.de/login antwortet mit 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:00:58 +02:00
sorbandClaude Opus 5 9deb205dce Merge branch 'rework/monitoring'
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:13:01 +02:00
24 changed files with 4046 additions and 979 deletions
+28
View File
@@ -0,0 +1,28 @@
# AGENTS.md — threadnet-operating
> **Die Gruppenregeln sind kanonisch im `management`-Repo:**
> [`AGENTS.md`](https://git.lab/axion1337.chat/management/-/blob/main/AGENTS.md)
> — von außerhalb des Labs über den Gitea-Mirror lesbar:
> `https://rohana.axion1337.de/sorb/management`. Dort stehen Repo-Topologie und
> Mirror-Regeln, das Kanban-Framework (Status-Labels, WIP-Limit 2, ADR-Pflicht),
> Deploy-Übergabe und AAR-Verfahren, Secrets-Handhabung und die
> Karpathy-Leitlinien. Sie gelten für **jede** Session in diesem Repo.
> Hier steht nur, was zusätzlich für dieses Repository gilt.
## Was dieses Repo ist
Monitoring-Stack auf **CFGMON**: Prometheus, Loki, Grafana, Alloy, Alertmanager und der
CVE-Exporter — als Compose, **nicht** von Flux verwaltet. Deploy manuell auf dem Host
(`/opt/threadnet-operating/monitoring`).
## Was hier besonders zählt
- **Alarmregeln** liegen in `monitoring/prometheus/alerts.yml`; die Zustellung läuft über
`alertmanager/matrix-alerts.py` in den Matrix-Security-Raum. Eine Regel, die nie feuern
kann, ist schlimmer als keine — **fehlende Zeitreihen erzeugen Stille, keinen Alarm**
(deshalb die `or`-Fallbacks in `axion-backup`).
- **Config-Änderungen kommen nicht automatisch an.** Prometheus und Alertmanager mounten seit
2026-08-15 ihr Config-**Verzeichnis**; bei den übrigen Diensten (loki, alloy, die Skripte)
hängt der Einzeldatei-Mount weiter am Inode und braucht `--force-recreate` nach `git pull`.
Details und Prüfbefehl in `monitoring/README.md`.
- Ob eine Änderung wirklich greift, sieht man **nur im Container**, nie auf der Platte.
+1
View File
@@ -0,0 +1 @@
Read AGENTS.md — the canonical instruction file for this repository. All rules live there.
+35
View File
@@ -0,0 +1,35 @@
# threadnet-operating
Der Betriebs-/Monitoring-Stack für den Operating-Host **CFGMON**: Prometheus,
Loki, Grafana, Alloy, Alertmanager und der CVE-Exporter — vollständig als Code,
ein `docker compose up -d` stellt ihn auf einem frischen Host wieder her.
**→ [`monitoring/README.md`](monitoring/README.md)** ist die eigentliche
Betriebsanleitung (Deployment, Config-Fallen, Alerting, CVE-Pipeline).
## Wo was liegt
| Pfad | Inhalt |
|---|---|
| `monitoring/` | der Stack: Compose, Prometheus, Loki, Grafana, Alertmanager, Alloy |
| `monitoring/cve/` | CVE-Exporter (Trivy-Scan → Prometheus-Metriken), [ADR-0003](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0003-cve-meldeweg-aggregiert.md) |
| `monitoring/grafana/` | Datasources und Dashboards als Code |
## Offene Punkte
Kein Backlog in diesem Repo. Offene Punkte sind **Issues** im
[management-Projekt](https://git.lab/axion1337.chat/management/-/issues)
([ADR-0005](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0005-pm-framework-kanban.md)) —
sie betreffen meist mehrere Hosts, eine Liste je Repo würde auseinanderlaufen.
Für diesen Stack einschlägig sind unter anderem
[#8 Remote-Write und Loki ohne Auth](https://git.lab/axion1337.chat/management/-/issues/8),
[#9 Grafana-Credentials](https://git.lab/axion1337.chat/management/-/issues/9) und
[#10 Gitea-Backups off-host](https://git.lab/axion1337.chat/management/-/issues/10);
Bestand und Historie zum Host stehen in
[`hosts/cfgmon.md`](https://git.lab/axion1337.chat/management/-/blob/main/hosts/cfgmon.md).
**Kanonisch ist git.lab** ([ADR-0001](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0001-gitlab-kanonisch-push-mirror.md),
[ADR-0002](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0002-issues-und-management-ins-lab.md)).
Von außerhalb des Labs ist derselbe Stand über den Push-Mirror
[`sorb/management`](https://rohana.axion1337.de/sorb/management) lesbar — dorthin
aber **nie pushen**, der Mirror überschreibt.
+14
View File
@@ -2,3 +2,17 @@
# Werte in Single-Quotes, damit Sonderzeichen shell-safe sind. # Werte in Single-Quotes, damit Sonderzeichen shell-safe sind.
GRAFANA_ADMIN_USER='admin' GRAFANA_ADMIN_USER='admin'
GRAFANA_ADMIN_PASSWORD='changeme' GRAFANA_ADMIN_PASSWORD='changeme'
# Alerting (gitops#32) - Matrix-Zustellung durch den eigenen Bot @alerts in den
# dedizierten Alerts-Raum im Operating-Space (Entscheidung 2026-07-31: eigener Bot
# + eigener Raum, Token-Trennung vom maintenance-notify-Bot auf dem MATRIX-Host).
# Token erzeugen (Wert direkt hier eintragen, nicht in Chats/Logs):
# kubectl exec -n matrix deploy/matrix-stack-matrix-authentication-service -- \
# mas-cli manage issue-compatibility-token alerts
# Bot ist dem Raum bereits beigetreten (2026-07-31, inkl. Avatar + Testnachricht).
MATRIX_ALERT_HOMESERVER=https://matrix.axion1337.chat
MATRIX_ALERT_ROOM_ID=!qavWkXbhLPfGvqtifj:axion1337.chat
MATRIX_ALERT_TOKEN=changeme
# CVE-/Release-Raum fuer release-watch (gitops#47) - @alerts dort einladen!
MATRIX_RELEASE_ROOM_ID=!YRJvcEbVXtRlUIkNld:axion1337.chat
+221 -7
View File
@@ -13,6 +13,45 @@ docker network create traefik # falls noch nicht vorhanden
docker compose up -d docker compose up -d
``` ```
### Config-Aenderungen: was wirklich ankommt
**Prometheus und Alertmanager mounten seit 2026-08-15 ihr Config-VERZEICHNIS**
(`./prometheus`, `./alertmanager`) statt einzelner Dateien. Damit ist die frueher
hier beschriebene Inode-Falle fuer sie beseitigt: `git pull` ersetzt Dateien per
Rename (neuer Inode); ein Einzeldatei-Mount zeigt danach weiter auf die **alte**
Datei, waehrend `SIGHUP` seelenruhig Erfolg meldet. Verzeichnis-Mounts loesen bei
jedem Zugriff ueber den Pfad auf.
Warum strukturell statt per Anleitung: genau diese Falle war hier bereits
ausfuehrlich dokumentiert -- samt richtigem Kommando und Pruefbefehl -- und hat am
2026-08-14 trotzdem zugeschlagen (geladen wurden die alten Alert-Regeln, Reload
meldete Erfolg). Eine Fussangel, die man nur durch Lesen umgeht, umgeht man
irgendwann nicht.
Nach einem `git pull`, der Configs anfasst:
```bash
docker compose up -d # Mount-/Service-Aenderungen -> Container wird neu erstellt
```
Reicht bei Prometheus/Alertmanager fuer den Inhalt bereits ein Reload, ist das in
Ordnung -- die Datei ist jetzt wirklich die neue.
**Noch als Einzeldatei gemountet** (gleiche Falle, dort weiterhin
`--force-recreate` noetig): `loki/loki-config.yaml`, `alloy/config.alloy` sowie die
Skripte `matrix-alerts.py`, `release-watch.py`, `cve-exporter.py`.
Ob eine Aenderung angekommen ist, sieht man nur **im Container**, nie auf der Platte:
```bash
docker exec prometheus md5sum /etc/prometheus/alerts.yml
md5sum prometheus/alerts.yml # muessen uebereinstimmen
```
Grafana liest **Provider-Definitionen** nur beim Start: ein neuer Ordner in
`provisioning/dashboards/dashboards.yml` braucht `docker compose restart grafana`.
Dashboard-JSONs innerhalb eines bestehenden Providers werden laufend nachgezogen.
## Struktur ## Struktur
| Pfad | Inhalt | | Pfad | Inhalt |
@@ -23,14 +62,23 @@ docker compose up -d
| `alloy/config.alloy` | Docker-Log-Collection -> Loki, node-exporter -> Prometheus | | `alloy/config.alloy` | Docker-Log-Collection -> Loki, node-exporter -> Prometheus |
| `grafana/provisioning/` | Datasources (feste UIDs!) und Dashboard-Provider | | `grafana/provisioning/` | Datasources (feste UIDs!) und Dashboard-Provider |
| `grafana/dashboards/<ordner>/*.json` | Dashboards, je Unterordner ein Grafana-Ordner | | `grafana/dashboards/<ordner>/*.json` | Dashboards, je Unterordner ein Grafana-Ordner |
| `cve/targets.py` | Bildet die Soll-Menge der Scan-Ziele aus Cluster, Betriebs-Host und Registry |
| `cve/test_targets.py` | Zusicherungen dazu, je mit Gegenprobe (`python3 test_targets.py`) |
| `cve/test_scan_loop.sh` | Zusicherungen fuer die Scan-Schleife (`sh cve/test_scan_loop.sh`) |
| `cve/scan-loop.sh` | Trivy-Scan-Schleife (24h), schreibt JSON-Reports |
| `cve/cve-exporter.py` | Reports -> Prometheus-Metriken, mit First-Seen-State |
## Externe Abhaengigkeiten ## Externe Abhaengigkeiten
- **Remote-Write eingehend** auf `:9090`: k3s-Cluster und Matrix-Server pushen - **Remote-Write eingehend** auf `:9090`: k3s-Cluster und Matrix-Server pushen
Metriken (`flux`, `kube_state_metrics`, `synapse`). Metriken (`flux`, `kube_state_metrics`, `synapse`).
- **Log-Push eingehend** auf `:3100` (Loki) von externen Alloys. - **Log-Push eingehend** auf `:3100` (Loki) von externen Alloys.
- **Scrape ausgehend**: k3s-Host (`10.0.0.2:9100`), Pterodactyl-Host - **Scrape ausgehend**: k3s-Host (`10.0.0.2:9100`), Game-Host privat ueber den
(`157.90.155.206:9100` + `:8080`). vSwitch (`10.0.0.4:9100`, `:8081` cadvisor, `:9810` gameserver-exporter).
cadvisor liegt dort auf 8081, weil Host-Port 8080 von coolify-proxy belegt ist.
Der Exporter auf `:9810` loest den alten auf `:9531` ab -- jener rief die
Client-API mit einem Application-Key ueber http auf und lieferte deshalb nie
eine einzige `pterodactyl_*`-Metrik.
- `cadvisor` und `traefik` werden gescraped, laufen aber in **anderen** Stacks - `cadvisor` und `traefik` werden gescraped, laufen aber in **anderen** Stacks
(cadvisor: Portainer-Stack `thread-net-git`; beide haengen im externen (cadvisor: Portainer-Stack `thread-net-git`; beide haengen im externen
`traefik`-Netz). `traefik`-Netz).
@@ -42,13 +90,179 @@ aber die JSON-Datei im Repo ist die Quelle der Wahrheit: UI-Aenderungen
muessen exportiert und committet werden, sonst gehen sie beim naechsten muessen exportiert und committet werden, sonst gehen sie beim naechsten
Datei-Update verloren. Datei-Update verloren.
## CVE-Pipeline (gitops#47)
`cve-scan` scannt alle 24h die Ziele aus `/targets/targets.txt` mit Trivy und legt
JSON-Reports in ein Volume; `cve-exporter` serviert sie als Metriken auf
`:9101`, Prometheus scraped sie als Job `cve_exporter`. Dashboard:
**Security / CVE-Uebersicht (Trivy)**.
Der Exporter merkt sich je `(CVE, Target)` den Erstfund in einem persistenten
State (`cve_exporter_state`-Volume) -- daher kommt die Spalte
"erstmals gesehen". Verschwindet ein Finding, faellt der Eintrag raus
(= "geschlossen").
### Die Zielmenge wird abgeleitet, NICHT gepflegt (#0106, ADR-0026)
Bis zum 2026-08-21 stand die Zielliste in `cve/images.txt` und musste bei
Stack-Aenderungen von Hand nachgezogen werden. Sie wurde es nicht: Gemessen an
dem Tag deckte sie **27 von 51** laufenden Images ab (52 %). Ungescannt blieben
unter anderem der Web-Client, den jeder Nutzer laedt, **beide** Traefik-Schichten
und die Registry selbst; gleichzeitig lief ein Scan gegen ein Image, das
nirgends mehr laeuft.
**Es gibt daher nichts mehr nachzuziehen.** Der `cve-exporter` bildet die
Soll-Menge bei jeder Herleitung neu aus drei Quellen und schreibt sie nach
`/targets/targets.txt` (Volume `cve_targets`, fuer den Scanner nur lesbar):
| Quelle | woher |
|---|---|
| Cluster | `kube_pod_container_info` aus dem eigenen Prometheus — **`image_spec`, nicht `image`** (siehe Warnung unten) |
| Betriebs-Host | `container_last_seen{job="operating_cadvisor"}` — schliesst `gameserver_cadvisor` aus |
| Registry | rohana `/v2`, anonymer Token; die letzten drei Fassungen je Repo |
Ein Ziel, das aus der Menge faellt, verliert seinen Bericht — sonst meldete ein
ausgemustertes Image weiter.
⚠️ **Ein per Digest festgenagelter Container (`repo:tag@sha256:...`) traegt in
`kube_pod_container_info` unter `image` nur den nackten `sha256:...`.** Die
brauchbare Referenz steht allein in `image_spec`. Am 2026-08-21 kam mit dem
alloy-Chart 1.x genau so ein Beiwagen herein; ohne diesen Zusatz waere er still
aus der Zielmenge gefallen. Der nackte Digest wird ausserdem ausdruecklich
abgewiesen, sonst landete er als Repo `sha256` in `targets.txt` und zaehlte
dauerhaft als Deckungsluecke.
⚠️ **Eine Antwort kann erfolgreich und trotzdem wertlos sein.** Am 2026-08-23
legte ein k3s-Neustart kube-state-metrics und Alloy lahm; `kube_pod_container_info`
war daraufhin in Prometheus **leer**. Die Abfrage lief sauber durch, lieferte
null Zeilen — und galt als Erfolg. Die Cluster-Quelle fiel von 39 Zielen auf 0,
die Gesamtmenge von 54 auf 12, der Scanner loeschte **42 Berichte**, und das
Dashboard meldete Deckung **1,0**. Keine Regel schlug an: die Menge war nicht
leer, die Zeitstempel waren frisch.
Seitdem gilt: **Eine Quelle, die schon einmal geliefert hat und jetzt nichts
liefert, ist ein Ausfall.** Der alte Stand bleibt, der Zeitstempel altert,
`CveTargetSourceStale` greift — und `CveZielmengeEingebrochen` meldet zusaetzlich
jeden Einbruch der Gesamtmenge um mehr als 40 %.
⚠️ **Faellt eine Quelle aus, wird die Soll-Menge kleiner — und die Deckung sieht
dadurch BESSER aus, nicht schlechter.** Deshalb traegt jede Quelle einen
Frische-Zeitstempel (`cve_target_source_stale`), auf dem eine Alarmregel steht.
Wer hier etwas aendert, darf diese Absicherung nicht wegkuerzen; ohne sie ist
ein Ausfall der Herleitung von Erfolg nicht zu unterscheiden.
Deckung im Blick: `cve_target_coverage_ratio`, `cve_targets_missing`,
`cve_targets_orphaned` — im Dashboard *Security / CVE-Uebersicht*.
### Entschieden oder offen (#0051)
Ein CRITICAL, zu dem es nichts zu tun gibt, ist erlaubt — aber nur **benannt,
begruendet und mit Pruefdatum**. Diese Entscheidungen stehen in
`cve/entscheidungen.json`, je Eintrag ein Ziel mit den **einzelnen**
CVE-Kennungen:
```json
{ "ziel": "coturn/coturn:4.17.2", "art": "hingenommen",
"grund": "4.17.2 ist die neueste veroeffentlichte Fassung ...",
"pruefen_am": "2026-11-21", "cves": ["CVE-...", "..."] }
```
`art` ist `hingenommen` (es gibt nichts zu tun) oder `geplant` (die Arbeit steht
aus).
⚠️ **Eintraege veralten aus zwei Gruenden, und beide sind normal.** Entweder das
Image verschwindet aus dem Bestand (Fassungssprung) — oder der Befund selbst
verschwindet, weil Trivys Datenbank ihn **umbewertet**. Am 2026-08-23 fiel
`CVE-2026-57433` (perl-base) von CRITICAL auf HIGH und machte damit sechs
Entscheidungen auf einen Schlag gegenstandslos. Ein Schweregrad ist keine
Eigenschaft unseres Bestands, sondern einer fremden Datenbank, die sich unter
uns aendert. `cve_entscheidungen_ohne_befund` faengt beide Faelle. Daraus zaehlt der Exporter `cve_critical_offen`,
`cve_critical_entschieden`, `cve_entscheidungen_abgelaufen` und
`cve_entscheidungen_ohne_befund`; vier Alarmregeln stehen darauf.
⚠️ **Bewusst keine Trivy-Ignore-Datei.** Trivy koennte die Befunde einfach
weglassen — dann saehe „0 CRITICAL, weil behoben" genauso aus wie „0 CRITICAL,
weil weggeschaut". Hier bleibt jeder Befund in `trivy_vuln_info` sichtbar;
entschieden heisst **gezaehlt, nicht versteckt**.
⚠️ **Pauschalen je Image sind nicht ausdrueckbar** (der Lader weist einen
Eintrag ohne CVE-Kennungen ab). Sonst verschluckte eine einmal getroffene
Entscheidung auch jeden **neuen** Befund an demselben Image.
⚠️ **Eine unlesbare Datei laesst alles als offen zaehlen**, nicht als
entschieden — die Richtung, die auffaellt. `CveEntscheidungenUnlesbar` nennt
den Grund sofort, `CveCriticalOffen` erst nach 24h.
⚠️ **Dieselbe Lage hat je nach Zaehlweise verschiedene Zahlen.** Ein Trivy-Bericht
zaehlt **Vorkommen**, Prometheus zaehlt **Serien** — und eine Serie ist
`(CVE, Paket, installierte Fassung, Fix-Fassung)` je Ziel. Wiederholt sich
derselbe Befund ueber mehrere Binaries in einem Image, sind das viele Vorkommen
und **eine** Serie. Bei Grafana 13.2.0: 162 Vorkommen, 63 Serien, 28
verschiedene CVEs. Wer eine Fassung gegen eine andere haelt, muss auf beiden
Seiten dieselbe Zaehlweise nehmen — sonst faellt das Urteil falsch aus, wie am
2026-08-21 beim ersten Blick auf 13.x.
Pruefen: `python3 cve/test_entscheidungen.py` (15 Zusicherungen, ohne Netz) und
`docker run --rm --entrypoint promtool -v "$PWD/prometheus:/p:ro"
prom/prometheus:v3.14.0 test rules /p/alerts_test.yml`.
### Alarm-Zustellung (frueher stummgeschaltet — seit gitops#51 wieder scharf)
**Stand 2026-08-15: Alarme werden zugestellt.** `alertmanager.yml` hat nur noch die
Default-Route auf den `matrix`-Receiver; die frueher hier beschriebene
`room="security"`-Route auf den Null-Receiver existiert nicht mehr. Alarme **ohne**
`room`-Label (u.a. die `axion-basics`- und `axion-backup`-Gruppen) laufen ueber
diese Default-Route.
Historie, damit der Grund der damaligen Stummschaltung nicht verlorengeht: die
CVE-Regeln erzeugten je eine Alarm-Instanz **pro CVE pro Image** (beim ersten Lauf
59 CRITICAL + 445 HIGH), `matrix-alerts.py` schickte eine Nachricht pro Alarm,
Synapse rate-limitete nach ~10 Nachrichten mit 429, und weil `save_state()` hinter
der Sende-Schleife stand, wiederholte Alertmanager die komplette Gruppe mit noch
leerer Deduplizierung. Behoben durch Aggregation auf `count by (target, severity)`
(eine Instanz je Image statt je CVE); Details im Dashboard `cve-overview`.
Vollstaendige Historie: gitops#51 / AAR 2026-08-01.
**Zustellfehler sind seit 2026-08-15 selbst ueberwacht:** Prometheus scrapt
Alertmanager (`operating_alertmanager`) und `AlertDeliveryFailing` schlaegt an,
wenn `alertmanager_notifications_failed_total` steigt. Vorher war Alertmanager zwar
Alarm-Ziel, aber kein Scrape-Target — eine reissende Alarmkette haette sich also
selbst nicht melden koennen.
### Kleinere offene Punkte der Pipeline
- `TrivyScanStale` kann ein Image, das **nie** erfolgreich gescannt wurde,
nicht melden: ohne ersten Report gibt es keine Serie, an der
`time() - trivy_last_scan_timestamp` haengen koennte. Ein dauerhaft
fehlschlagendes Image bleibt damit still. `TargetDown` deckt nur den toten
Exporter ab, nicht den einzelnen blinden Fleck.
- `coturn/coturn:latest` ist als einziges Image ungepinnt -- Scan-Ergebnisse
sind dadurch nicht reproduzierbar.
- Der Exporter prunt den First-Seen-State bei **jedem** Scrape anhand der
gerade gelesenen Reports. Ein transienter Lesefehler (`except: continue`)
loescht die Erstfund-Zeitstempel des betroffenen Targets dauerhaft.
## Offene Punkte / Sicherheit ## Offene Punkte / Sicherheit
- `9090`, `3100`, `9100` sind auf der oeffentlichen IP ohne Auth erreichbar - `9090`, `3100`, `9100`: **erledigt am 2026-08-21.** Sie standen auf `0.0.0.0`
(Docker umgeht ufw). Per **Hetzner Cloud Firewall** auf die IPs der und waren damit auf der oeffentlichen IP ohne Auth erreichbar (Docker umgeht
bekannten Absender einschraenken. ufw). Jetzt an Adressen gebunden -- `10.0.0.3` fuer die pushenden Absender
- Pterodactyl-Host `157.90.155.206`: node-exporter und cadvisor antworten ueber den vSwitch, `127.0.0.1` fuer den Host selbst; node-exporter nur
aktuell nicht (Targets down). localhost. Die Hetzner-Firewall bleibt die zweite Schicht, ist aber nicht
mehr die einzige.
⚠️ **Das war auf dem Host repariert und im Repo nicht.** Vier Monate spaeter
haette ein `git pull` die Ports wieder geoeffnet, ohne dass jemand es
bemerkt haette -- verhindert nur dadurch, dass git den Pull wegen der
lokalen Aenderung abgelehnt hat. Eine Haertung, die nur auf dem Host steht,
ist keine Haertung, sondern eine Wette auf das naechste Deployment.
- Game-Host: **erledigt**. Host-Seite am 2026-08-20 deployt und verifiziert, die
Ports liegen auf `10.0.0.4` (nicht `0.0.0.0` -- das umginge ufw). Der lokale
Prometheus/Grafana/Loki-Stack des Game-Hosts ist zurueckgebaut; er liefert nur
noch Sammler, promtail schickt nach `10.0.0.3:3100`.
Das Label-Konzept des Game-Hosts lebte in `metric_relabel_configs` und ist mit
dem Rueckbau hierher gewandert -- ohne es blieben Container nur unter ihrer UUID
sichtbar und je Serie rund 20 `container_label_*` uebrig.
- Loki `table_manager` ist deprecated; Retention-Enforcement laeuft aktuell - Loki `table_manager` ist deprecated; Retention-Enforcement laeuft aktuell
nicht ueber den Compactor -- bei Gelegenheit auf Compactor-Retention nicht ueber den Compactor -- bei Gelegenheit auf Compactor-Retention
umstellen. umstellen.
+16
View File
@@ -0,0 +1,16 @@
# Alertmanager (gitops#32): alles an den Matrix-Receiver (wartung-Raum).
route:
receiver: matrix
group_by: [alertname, instance]
group_wait: 1m
group_interval: 5m
repeat_interval: 4h
# CVE-Alarme laufen seit gitops#51 aggregiert (eine Instanz pro Image statt
# pro CVE) und wieder ueber den Matrix-Receiver — Historie des Alarm-Sturms
# und der Stummschaltung: gitops#51 / AAR 2026-08-01.
receivers:
- name: matrix
webhook_configs:
- url: http://matrix-alerts:8080/alert
send_resolved: true
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
# Minimaler Alertmanager-Webhook -> Matrix-Raum (gitops#32). Gleiche Machart wie
# maintenance-notify (Issue #24 im gitops-Repo): purer Stdlib-HTTP-Server, Bot-Token
# aus der Umgebung, Nachricht per Client-Server-API.
#
# Ein Alarm = EINE Nachricht: beim Firing wird pro Alarm (Alertmanager-Fingerprint)
# eine Nachricht gesendet und deren Event-ID gemerkt; beim Resolved wird dieselbe
# Nachricht per m.replace-Edit durchgestrichen und abgehakt statt eine neue zu
# posten (Wunsch sorb 2026-08-01: append-only wird unuebersichtlich). Die Zuordnung
# ueberlebt Container-Restarts via State-Datei; ohne Zuordnung (z.B. nach Neubau)
# faellt Resolved auf eine eigenstaendige ✅-Nachricht zurueck.
import html
import json
import os
import time
import urllib.parse
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOMESERVER = os.environ["MATRIX_HOMESERVER"]
ROOM_ID = os.environ["MATRIX_ROOM_ID"]
TOKEN = os.environ["MATRIX_TOKEN"]
STATE_FILE = os.environ.get("MATRIX_STATE_FILE", "/tmp/matrix-alerts-state.json")
# Raum-Routing (gitops#47): Alerts mit Label room="<name>" landen im Raum aus
# MATRIX_ROOM_<NAME> (z.B. room="security" -> MATRIX_ROOM_SECURITY), alles
# andere im Default-Raum. Der Bot muss in jedem Zielraum Mitglied sein.
ROOM_MAP = {k[len("MATRIX_ROOM_"):].lower(): v
for k, v in os.environ.items()
if k.startswith("MATRIX_ROOM_") and k != "MATRIX_ROOM_ID" and v}
try:
with open(STATE_FILE) as f:
_persisted = json.load(f)
state = _persisted.get("alerts", _persisted) # alt: flaches Format
recent_resolved = _persisted.get("recent_resolved", [])
except Exception:
state = {}
recent_resolved = []
def save_state():
try:
with open(STATE_FILE, "w") as f:
json.dump({"alerts": state, "recent_resolved": recent_resolved[-200:]}, f)
except Exception as e:
print(f"state save failed: {e}", flush=True)
def matrix_put(content, room=ROOM_ID):
url = (f"{HOMESERVER}/_matrix/client/v3/rooms/{urllib.parse.quote(room)}"
f"/send/m.room.message/{uuid.uuid4()}")
req = urllib.request.Request(url, data=json.dumps(content).encode(), method="PUT",
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=10).read()).get("event_id")
def send_text(text, room=ROOM_ID):
return matrix_put({"msgtype": "m.text", "body": text}, room)
def edit_resolved(event_id, old_text, room=ROOM_ID):
# m.replace-Edit: Original wird in Element in-place ersetzt (durchgestrichen + Haken)
new_body = f"✅ ~~{old_text}~~"
new_html = f"✅ <del>{html.escape(old_text)}</del>"
matrix_put({
"msgtype": "m.text",
"body": f"* {new_body}",
"m.new_content": {"msgtype": "m.text", "body": new_body,
"format": "org.matrix.custom.html", "formatted_body": new_html},
"m.relates_to": {"rel_type": "m.replace", "event_id": event_id},
}, room)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/alert":
self.send_response(404); self.end_headers(); return
data = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
failed = 0
for a in data.get("alerts", []):
name = a.get("labels", {}).get("alertname", "?")
summary = a.get("annotations", {}).get("summary", "")
fp = a.get("fingerprint", "")
room = ROOM_MAP.get(a.get("labels", {}).get("room", ""), ROOM_ID)
try:
if a.get("status") == "firing":
if fp in state: # re-notify/Batch-Retry -> keine Doppelnachricht
continue
text = f"\U0001F534 [firing] {name}: {summary}"
event_id = send_text(text, room)
if fp and event_id:
state[fp] = {"event_id": event_id, "text": text, "room": room}
else:
if fp in recent_resolved: # Batch-Retry -> Fallback nicht doppeln
continue
known = state.pop(fp, None)
if known:
edit_resolved(known["event_id"], known["text"], known.get("room", ROOM_ID))
else:
send_text(f"✅ [resolved] {name}: {summary}", room)
recent_resolved.append(fp)
save_state() # inkrementell: Teilfortschritt uebersteht Fehler/Retry
time.sleep(1) # Synapse-Ratelimit (rc_message) nicht reizen
except Exception as e:
failed += 1
self.log_message("matrix send failed (%s): %s", name, e)
save_state()
time.sleep(2)
if failed:
# Alertmanager wiederholt den Batch; Dedup liefert nur den Rest nach
self.send_response(502); self.end_headers(); return
self.send_response(200); self.end_headers()
def log_message(self, fmt, *args):
print(fmt % args, flush=True)
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
# Release-/Advisory-Watch fuer den Element-Stack (gitops#22). Gleiche Machart wie
# matrix-alerts.py: purer Stdlib-Daemon, Bot-Token aus der Umgebung, Nachricht per
# Client-Server-API in den Alerts-Raum.
#
# Quelle sind die oeffentlichen GitHub-Release-Atom-Feeds (kein API-Token noetig).
# Element veroeffentlicht Security-Fixes als Releases - der Feed ist damit der
# praktikable Advisory-Kanal; echte GHSA-Advisories haben keinen oeffentlichen Feed.
# Neue Eintraege werden einmalig als 📦-Notiz gemeldet; Security-verdaechtige
# Titel/Inhalte (CVE/security/vulnerab...) bekommen 🚨 und stehen vorn.
#
# Abgrenzung (Frage sorb 2026-08-01): REPOS unten ist eine HANDGEPFLEGTE Liste -
# de facto ein Mini-SBOM auf Repo-Granularitaet, ohne Versions-/Dependency-Wissen.
# Bei Stack-Aenderungen (neue Komponente, Fork-Wechsel) muss sie mitgezogen werden.
# Trivy (gitops#31/#47) braucht dagegen keine Pflege: es leitet sein SBOM selbst
# aus den Images ab. Beide ergaenzen sich, ersetzen sich nicht.
import json
import os
import re
import time
import urllib.parse
import urllib.request
import uuid
import xml.etree.ElementTree as ET
HOMESERVER = os.environ["MATRIX_HOMESERVER"]
# Eigener CVE-/Release-Raum (Entscheidung sorb 2026-08-01, gitops#47);
# Fallback auf den Alerts-Raum, solange der Bot dort noch nicht eingeladen ist.
ROOM_ID = os.environ.get("MATRIX_RELEASE_ROOM_ID") or os.environ["MATRIX_ROOM_ID"]
TOKEN = os.environ["MATRIX_TOKEN"]
STATE_FILE = os.environ.get("RELEASE_WATCH_STATE_FILE", "/state/release-watch.json")
INTERVAL = int(os.environ.get("RELEASE_WATCH_INTERVAL", "21600")) # 6h
REPOS = [
"element-hq/synapse",
"element-hq/ess-helm",
"element-hq/element-web",
"element-hq/matrix-authentication-service",
"element-hq/element-call",
]
SECURITY_RE = re.compile(r"cve|security|vulnerab|advisory", re.IGNORECASE)
ATOM = "{http://www.w3.org/2005/Atom}"
try:
with open(STATE_FILE) as f:
seen = json.load(f) # repo -> [entry ids]
except Exception:
seen = {}
def send_notice(text):
url = (f"{HOMESERVER}/_matrix/client/v3/rooms/{urllib.parse.quote(ROOM_ID)}"
f"/send/m.room.message/{uuid.uuid4()}")
req = urllib.request.Request(url, data=json.dumps({"msgtype": "m.notice", "body": text}).encode(),
method="PUT", headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10).read()
def check(repo):
feed = urllib.request.urlopen(f"https://github.com/{repo}/releases.atom", timeout=20).read()
root = ET.fromstring(feed)
entries = root.findall(f"{ATOM}entry")
known = set(seen.get(repo, []))
first_run = repo not in seen
new = []
for e in entries:
eid = e.findtext(f"{ATOM}id", "")
title = e.findtext(f"{ATOM}title", "?").strip()
link = ""
le = e.find(f"{ATOM}link")
if le is not None:
link = le.get("href", "")
content = e.findtext(f"{ATOM}content", "") or ""
if eid and eid not in known:
new.append((eid, title, link, bool(SECURITY_RE.search(title + " " + content[:2000]))))
# Erstlauf: nur Stand merken, nicht den Raum mit Historie fluten
seen[repo] = [e.findtext(f"{ATOM}id", "") for e in entries][:30]
if first_run:
return
for eid, title, link, is_sec in new:
icon = "\U0001F6A8" if is_sec else "\U0001F4E6"
kind = "Security-verdaechtiges Release" if is_sec else "Neues Release"
send_notice(f"{icon} {kind}: {repo}{title}\n{link}")
while True:
for repo in REPOS:
try:
check(repo)
except Exception as exc:
print(f"{repo}: {exc}", flush=True)
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as f:
json.dump(seen, f)
except Exception as exc:
print(f"state: {exc}", flush=True)
time.sleep(INTERVAL)
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
# Trivy-JSON -> Prometheus-Metriken (gitops#47). Stdlib-only, Machart wie die
# uebrigen Monitoring-Helfer. Liest die Reports des cve-scan-Sidecars und
# serviert /metrics; merkt sich je (CVE, Target) den Erstfund (first_seen),
# damit der geforderte Zeitstrahl (erstmals gesehen / geschlossen) abbildbar
# ist - "geschlossen" = Serie verschwindet, resolved kommt via Alertmanager.
#
# Schema (Pflichtfelder-Vorgabe sorb: CVE-ID, Mitigation, Zeitstrahl, Ort, Typ):
# trivy_vuln_info{cve,severity,target,target_type,host,pkg,installed,fixed_version} 1
# (nur HIGH/CRITICAL als Einzelserien - Kardinalitaet)
# trivy_vuln_count{target,target_type,host,severity} <n> (alle Severities)
# trivy_vuln_first_seen_timestamp{cve,target} <unix>
# trivy_last_scan_timestamp{target,target_type,host} <unix>
import json
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import targets as ziel_herleitung
RESULTS = os.environ.get("RESULTS_DIR", "/results")
STATE_FILE = os.environ.get("STATE_FILE", "/state/first-seen.json")
# Soll-Menge (#0106): hierhin schreibt der Exporter, von hier liest scan-loop.sh.
TARGETS_FILE = os.environ.get("TARGETS_FILE", "/targets/targets.txt")
# ⚠️ Die Herleitung laeuft NICHT je Scrape. Bei 15 s Scrape-Intervall waeren das
# rund 21 000 Registry-Anfragen am Tag; eine Runde kostet ~90.
SOLL_INTERVALL = float(os.environ.get("SOLL_INTERVALL_SEKUNDEN", "3600"))
PROZESSSTART = time.time()
# Ort: Container-Images laufen (bis auf Weiteres) alle auf dem MATRIX-Host;
# Host-rootfs-Scans (Ausbaustufe) bringen ihren Hostnamen im Dateinamen mit.
DEFAULT_HOST = os.environ.get("DEFAULT_HOST", "matrix")
# Entschiedene Befunde (#0051): CRITICAL, zu dem es nichts mehr zu tun gibt oder
# zu dem die Arbeit noch aussteht - mit Begruendung und Pruefdatum.
ENTSCHEIDUNGEN_FILE = os.environ.get("ENTSCHEIDUNGEN_FILE", "/app/entscheidungen.json")
try:
with open(STATE_FILE) as f:
first_seen = json.load(f) # "cve|target" -> unix-ts
except Exception:
first_seen = {}
def esc(v):
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
def lade_entscheidungen(pfad=None):
"""Entscheidungen -> {(ziel, cve): (art, ablauf_unix)} plus Fehlertext.
⚠️ Bewusst KEINE Unterdrueckung. Trivy koennte die Befunde ueber eine
Ignore-Datei einfach weglassen - dann saehe "0 CRITICAL, weil behoben"
genauso aus wie "0 CRITICAL, weil weggeschaut". Hier bleibt jeder Befund in
trivy_vuln_info stehen; entschieden wird er nur zusaetzlich gezaehlt.
⚠️ Ein Lesefehler gibt eine LEERE Menge zurueck und meldet ihn. Damit zaehlt
im Zweifel alles als offen - die Richtung, die auffaellt. Umgekehrt saehe
eine kaputte Datei aus wie "alles entschieden".
"""
pfad = pfad or ENTSCHEIDUNGEN_FILE
getroffen = {}
try:
with open(pfad) as fh:
doc = json.load(fh)
except FileNotFoundError:
return getroffen, "keine Datei"
except Exception as e:
return getroffen, f"unlesbar: {e}"
for e in doc.get("eintraege") or []:
ziel = ziel_herleitung.normalisiere(e.get("ziel", ""))
art = e.get("art", "?")
datum = e.get("pruefen_am", "")
try:
ablauf = time.mktime(time.strptime(datum, "%Y-%m-%d"))
except Exception:
return {}, f"Pruefdatum unbrauchbar bei {e.get('ziel')}: {datum!r}"
if not ziel or not e.get("cves"):
return {}, f"Eintrag ohne Ziel oder ohne CVE-Kennung: {e.get('ziel')!r}"
for cve in e["cves"]:
getroffen[(ziel, cve)] = (art, ablauf)
return getroffen, None
class ZielCache:
"""Haelt die zuletzt gebildete Soll-Menge und erneuert sie im eigenen Takt.
⚠️ Der Stand je Quelle ueberlebt einen Ausfall absichtlich: Faellt eine
Quelle aus, bleibt ihr alter Zeitstempel stehen und ALTERT, statt zu
verschwinden. Eine verschwundene Serie kann keine Alarmregel ausloesen
(AAR 2026-08-01, Befund 3) - genau daran waere die Absicherung sonst
gescheitert.
"""
def __init__(self):
self._sperre = threading.Lock()
self._stand = {}
self._letzte = None
self._geholt_um = 0.0
def hole(self):
with self._sperre:
if self._letzte is not None and time.time() - self._geholt_um < SOLL_INTERVALL:
return self._letzte
cfg = ziel_herleitung.cfg_aus_umgebung()
h = ziel_herleitung.herleiten(
cfg, vorheriger_stand=self._stand,
vorherige_ziele=(self._letzte.je_quelle if self._letzte else None))
self._stand = dict(h.stand)
self._letzte, self._geholt_um = h, time.time()
for quelle, meldung in h.fehler.items():
print(f"soll-menge: quelle {quelle} ausgefallen: {meldung}", flush=True)
if h.brauchbar:
schreibe_targets(h.ziele)
else:
# NICHTS ueberschreiben. Sonst raeumt ein Neustart waehrend eines
# Prometheus-Ausfalls den gesamten Bestand ab.
print("soll-menge: alle Quellen aus - targets.txt bleibt unberuehrt", flush=True)
return h
def schreibe_targets(ziele):
try:
os.makedirs(os.path.dirname(TARGETS_FILE), exist_ok=True)
tmp = TARGETS_FILE + ".tmp"
with open(tmp, "w") as f:
f.write("# erzeugt von cve-exporter (#0106) - NICHT von Hand pflegen\n")
for z in sorted(ziele):
f.write(z + "\n")
os.replace(tmp, TARGETS_FILE) # atomar, der Scanner liest nebenlaeufig
except Exception as e:
print(f"targets.txt nicht schreibbar: {e}", flush=True)
ziel_cache = ZielCache()
def collect():
lines = []
now = int(time.time())
seen_keys = set()
gelesene_targets = set() # nur DEREN Erstfunde duerfen geprunt werden
berichte = 0
lesefehler = 0
kritisch = set() # (normalisiertes Ziel, CVE) - Grundlage fuer "offen"
for fn in sorted(os.listdir(RESULTS)):
if not fn.endswith(".json"):
continue
berichte += 1
path = os.path.join(RESULTS, fn)
try:
rep = json.load(open(path))
except Exception as e:
# Frueher: stilles 'continue'. Ein einmaliger Lesefehler (Datei wird
# gerade geschrieben, kurzer I/O-Fehler) liess die Findings dieses
# Targets aus seen_keys verschwinden - und der Prune unten loeschte
# ihre Erstfund-Zeitstempel DAUERHAFT. Der Zeitstrahl war damit weg,
# ohne dass irgendetwas gemeldet haette. Jetzt zaehlbar und sichtbar.
lesefehler += 1
print(f"report unlesbar: {fn}: {e}", flush=True)
continue
target = rep.get("ArtifactName", fn[:-5])
gelesene_targets.add(target)
ttype = "host" if rep.get("ArtifactType") in ("filesystem", "rootfs") else "image"
host = fn.split("__host__")[1].split(".json")[0] if "__host__" in fn else DEFAULT_HOST
base = f'target="{esc(target)}",target_type="{ttype}",host="{esc(host)}"'
lines.append(f'trivy_last_scan_timestamp{{{base}}} {int(os.path.getmtime(path))}')
counts = {}
for res in rep.get("Results") or []:
for v in res.get("Vulnerabilities") or []:
sev = v.get("Severity", "UNKNOWN")
counts[sev] = counts.get(sev, 0) + 1
if sev in ("HIGH", "CRITICAL"):
cve = v.get("VulnerabilityID", "?")
if sev == "CRITICAL":
kritisch.add((ziel_herleitung.normalisiere(target), cve))
key = f"{cve}|{target}"
if key not in first_seen:
first_seen[key] = now
seen_keys.add(key)
lines.append(
'trivy_vuln_info{cve="%s",severity="%s",%s,pkg="%s",installed="%s",fixed_version="%s"} 1'
% (esc(cve), sev, base, esc(v.get("PkgName", "?")),
esc(v.get("InstalledVersion", "?")), esc(v.get("FixedVersion", ""))))
lines.append(f'trivy_vuln_first_seen_timestamp{{cve="{esc(cve)}",target="{esc(target)}"}} {first_seen[key]}')
for sev, n in sorted(counts.items()):
lines.append(f'trivy_vuln_count{{{base},severity="{sev}"}} {n}')
# State kompakt halten: verschwundene Findings raus (= "geschlossen").
# ABER nur fuer Targets, deren Bericht in DIESEM Durchgang auch wirklich
# gelesen wurde. Sonst loescht ein einzelner Lesefehler den Zeitstrahl eines
# Targets, das es noch gibt - unwiederbringlich, weil "erstmals gesehen"
# danach auf 'jetzt' neu anfaengt.
for k in list(first_seen):
ziel = k.split("|", 1)[1] if "|" in k else ""
if ziel in gelesene_targets and k not in seen_keys:
del first_seen[k]
# Sichtbarkeit des Scanners selbst: ein still gestorbener Scanner macht blind,
# und TrivyScanStale kann ein Target, das NIE einen Bericht hatte, nicht melden
# (ohne Serie kein time()-Vergleich). Diese beiden Zahlen schliessen die Luecke
# so weit, wie sie ohne Soll-Liste zu schliessen ist.
lines.append(f"trivy_reports_total {berichte}")
lines.append(f"trivy_report_read_errors {lesefehler}")
# --- Deckung gegen die Soll-Menge (#0106) --------------------------------
# Bis hierher konnte der Exporter nur sagen, WAS er gescannt hat. Was er nie
# angesehen hat, war unsichtbar - am 2026-08-21 waren das 24 von 51 Images.
# Erst der Vergleich gegen eine Soll-Menge macht die Luecke zur Zahl.
h = ziel_cache.hole()
ist = {ziel_herleitung.normalisiere(t) for t in gelesene_targets}
ist.discard(None)
soll = h.ziele
for quelle in ziel_herleitung.QUELLEN:
for z in sorted(h.je_quelle.get(quelle, ())):
lines.append(f'cve_target_desired{{target="{esc(z)}",quelle="{quelle}"}} 1')
# ⚠️ IMMER ausgeben, auch wenn die Quelle noch nie geliefert hat: Eine
# fehlende Serie kann keine Regel ausloesen, und eine ausgefallene
# Herleitung sieht sonst aus wie vollstaendige Deckung. Ohne je einen
# Erfolg zaehlt die Zeit seit Prozessstart - der Wert waechst also und
# schlaegt irgendwann an, statt still zu fehlen.
seit = h.stand.get(quelle, PROZESSSTART)
lines.append(f'cve_target_source_stale{{quelle="{quelle}"}} {int(now - seit)}')
fehlend, verwaist = soll - ist, ist - soll
lines.append(f"cve_targets_missing {len(fehlend)}")
lines.append(f"cve_targets_orphaned {len(verwaist)}")
lines.append(f"cve_targets_desired_total {len(soll)}")
# Bei leerer Soll-Menge waere 0/0 rechnerisch 1,0 - also "alles gedeckt",
# ausgerechnet im Ausfall. Deshalb 0 statt einer Division.
lines.append(
f"cve_target_coverage_ratio {len(soll & ist) / len(soll) if soll else 0}"
)
# --- Entschieden oder offen (#0051, Kriterien 1 und 5) -------------------
# Kriterium 1 lautet "kein CRITICAL in einem laufenden Image OHNE
# Entscheidung". Das ist erst eine Zahl, wenn beide Seiten zaehlbar sind:
# die Befunde (oben) und die Entscheidungen (hier).
getroffen, fehler = lade_entscheidungen()
if fehler:
print(f"entscheidungen: {fehler}", flush=True)
# ⚠️ Nur Befunde auf Zielen, die WIRKLICH betrieben werden. Ein verwaister
# Bericht (Image abgeloest, Runde noch nicht gelaufen) darf die Zahl nicht
# hochhalten - sonst sieht ein erfolgreiches Update wie Stillstand aus.
#
# ⚠️ ABER: Ist die Soll-Menge leer, ist sie nicht "leer", sondern UNBEKANNT -
# die Herleitung ist ausgefallen. Dann darf hier nicht 0 herauskommen, denn
# 0 liest sich wie "nichts offen". In dem Fall zaehlen alle Berichte, auch
# verwaiste: lieber zu viel melden als im Ausfall Entwarnung geben.
laufend = {p for p in kritisch if p[0] in soll} if soll else set(kritisch)
offen = laufend - set(getroffen)
lines.append(f"cve_critical_offen {len(offen)}")
lines.append(f"cve_critical_entschieden {len(laufend & set(getroffen))}")
lines.append(f"cve_entscheidungen_lesefehler {1 if fehler else 0}")
abgelaufen = 0
ohne_befund = 0
for (ziel, cve), (art, ablauf) in sorted(getroffen.items()):
lines.append(
f'cve_entscheidung_gueltig_bis{{target="{esc(ziel)}",cve="{esc(cve)}",art="{esc(art)}"}} {int(ablauf)}'
)
if now > ablauf:
abgelaufen += 1
# ⚠️ Eine Entscheidung ohne Befund ist Altpapier: das Image ist ersetzt
# oder der Befund weg. Sie zaehlt hier, damit die Datei nicht zuwaechst
# und irgendwann Befunde deckt, die niemand mehr geprueft hat.
#
# ⚠️ KEIN "und ziel in soll" mehr. Genau diese Bedingung stand hier bis
# zum 2026-08-22 und machte den Zaehler blind fuer den haeufigsten Fall:
# Ist das Image ganz aus dem Bestand gefallen (Fassungssprung), faellt
# auch seine Entscheidung aus der Pruefung - und bleibt fuer immer
# unbemerkt in der Datei stehen. Belegt am selben Tag: die Eintraege zu
# grafana 12.0.0 und 12.4.9 ueberlebten den Sprung auf 13.2.0, und der
# Zaehler meldete 0.
#
# Der Preis ist bekannt und tragbar: Wer eine Entscheidung trifft, BEVOR
# der Scanner das Image erreicht, sieht sie bis zur naechsten Runde hier
# auftauchen. Die Alarmregel wartet 24h und deckt das ab.
if (ziel, cve) not in laufend:
ohne_befund += 1
lines.append(f"cve_entscheidungen_abgelaufen {abgelaufen}")
lines.append(f"cve_entscheidungen_ohne_befund {ohne_befund}")
lines.append(f"cve_entscheidungen_total {len(getroffen)}")
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as f:
json.dump(first_seen, f)
except Exception as e:
print(f"state save failed: {e}", flush=True)
return "\n".join(lines) + "\n"
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/metrics":
self.send_response(404); self.end_headers(); return
body = collect().encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4")
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
pass
# ⚠️ Unter __main__, damit test_entscheidungen.py die ECHTE Datei importieren
# kann statt eine zurechtgeschnittene Kopie. Ohne die Wache startet beim
# Import der Server, und der Test haette einen Nachbau geprueft - genau die
# Fehlerklasse, die scan-loop.sh am 2026-08-21 schon einmal gekostet hat.
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 9101), Handler).serve_forever()
+183
View File
@@ -0,0 +1,183 @@
{
"_hinweis": "Entschiedene CRITICAL-Befunde (#0051, Abnahmekriterien 1 und 5).\nJEDER Eintrag nennt die CVE-Kennungen EINZELN - eine Pauschale je Image wuerde\nauch jeden NEUEN Befund verschlucken, und genau das soll nicht passieren.\n'pruefen_am' ist bindend: der cve-exporter zaehlt abgelaufene Entscheidungen,\nder Alarm CveEntscheidungAbgelaufen meldet sie. art=hingenommen heisst 'es gibt\nnichts zu tun', art=geplant heisst 'die Arbeit steht aus'.\n⚠️ Nichts hiervon wird unterdrueckt: die Befunde bleiben in trivy_vuln_info\nsichtbar. Entschieden heisst gezaehlt, nicht versteckt.",
"eintraege": [
{
"ziel": "coturn/coturn:4.17.2",
"art": "hingenommen",
"grund": "4.17.2 ist die neueste veroeffentlichte Fassung (danach nur 'edge'). Alle Befunde sitzen ohne Fix-Fassung in libxml2, perl-base, liblmdb0 und mariadb-common der Debian-Basis - es gibt kein Image, das sie schliesst. coturn laeuft mit hostNetwork ausserhalb der NetworkPolicy und ist ueber die Hetzner-Firewall abgeschottet.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2019-16224",
"CVE-2019-16225",
"CVE-2019-16227",
"CVE-2026-13221",
"CVE-2026-42496",
"CVE-2026-44172",
"CVE-2026-49261",
"CVE-2026-6653",
"CVE-2026-8376"
]
},
{
"ziel": "gcr.io/cadvisor/cadvisor:v0.49.1",
"art": "geplant",
"grund": "Laeuft im Portainer-Stack 'thread-net-git', NICHT aus diesem Repo - die Fassung wird hier gar nicht gesetzt. GEMESSEN: v0.55.1 traegt 4 statt 5 CRITICAL, also ein schwacher Gewinn. Die Aktualisierung liegt wie beim portainer-agent bei sorb auf dem Betriebs-Host.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2024-24790",
"CVE-2024-37371",
"CVE-2024-41110",
"CVE-2025-68121",
"CVE-2026-33186"
]
},
{
"ziel": "ghcr.io/goauthentik/server:2026.8.0",
"art": "hingenommen",
"grund": "Heute erst auf die neueste Fassung gehoben (2026.2.3 -> 2026.8.0, -22 CRITICAL). Die verbliebenen fuenf sitzen in libxml2 und perl-base der Basis und haben keine Fix-Fassung.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2026-13221",
"CVE-2026-42496",
"CVE-2026-6653",
"CVE-2026-8376"
]
},
{
"ziel": "ghcr.io/requarks/wiki:2.5",
"art": "hingenommen",
"grund": "GEMESSEN, nicht vermutet: das neuere Release-Tag 2.5.277 traegt 40 CRITICAL statt 12. Das Update waere eine Verschlechterung. Wiki.js 3 ist ein eigenes Vorhaben.",
"pruefen_am": "2026-10-21",
"cves": [
"CVE-2021-23358",
"CVE-2023-45133",
"CVE-2025-54419",
"CVE-2025-7783",
"CVE-2026-28292",
"CVE-2026-33845",
"CVE-2026-42010",
"CVE-2026-59873"
]
},
{
"ziel": "gnuxie/draupnir:v3.1.0",
"art": "hingenommen",
"grund": "v3.1.0 ist die neueste Release; 'develop' ist keine. Die fuenf mit Fix-Fassung sind Debian- und npm-Pakete, die das veroeffentlichte Image nicht neu gebaut hat - 'Paket hat einen Fix' heisst nicht 'es gibt ein Image damit'.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2021-23358",
"CVE-2023-45853",
"CVE-2025-7783",
"CVE-2026-13221",
"CVE-2026-33845",
"CVE-2026-42010",
"CVE-2026-42496",
"CVE-2026-59873",
"CVE-2026-8376"
]
},
{
"ziel": "oci.element.io/synapse:v1.158.0",
"art": "hingenommen",
"grund": "Fassung vom ESS-Chart 26.8.0 gesetzt und heute erst gehoben (v1.151.0-ess.1 -> v1.158.0, 11 CRITICAL -> 6). Fuenf der sechs sitzen ohne Fix-Fassung in libxml2 und perl-base der Debian-Basis; der sechste ist die Go-stdlib des mitgelieferten Werkzeugs. Ein eigenes Image zu bauen, nur um diese Basis zu tauschen, waere unverhaeltnismaessig - der naechste ESS-Sprung bringt sie mit.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121",
"CVE-2026-13221",
"CVE-2026-42496",
"CVE-2026-6653",
"CVE-2026-8376"
]
},
{
"ziel": "portainer/agent:2.27.5",
"art": "geplant",
"grund": "GEMESSEN: 2.44.0 traegt 0 CRITICAL. Der Agent wird nicht aus einem unserer Repos ausgerollt, die Aktualisierung liegt bei sorb auf dem Betriebs-Host.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121",
"CVE-2026-33186"
]
},
{
"ziel": "postgres:15-alpine",
"art": "hingenommen",
"grund": "Datenbank-Client der Wiki-Gruppenpruefung (#0103), schwebendes Tag auf der neuesten 15er-Fassung. Der eine Befund ist die Go-stdlib des Basis-Images - nur ein Neubau des Images schliesst ihn.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "postgres:16-alpine",
"art": "hingenommen",
"grund": "Wiki.js-Datenbank, schwebendes Tag auf der neuesten 16er-Fassung. Befund wie bei 15-alpine in der Go-stdlib des Basis-Images.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "postgres:17-alpine",
"art": "hingenommen",
"grund": "Datenbank der ESS-Stacks, Fassung vom Chart 26.8.0 gesetzt - heute erst aktualisiert. Der eine Befund ist die Go-stdlib des Basis-Images; ein Neubau des Images schliesst ihn, ein Tag-Wechsel nicht.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "postgres:17.11-bookworm",
"art": "hingenommen",
"grund": "Neueste 17er-Bookworm-Fassung; kommt als Chart-Abhaengigkeit von Authentik mit und ist beim Sprung heute selbst von 17.9 auf 17.11 mitgewandert. Die Befunde sitzen in libxml2, libsqlite3, perl-modules und zlib der Debian-Basis. Ein Sprung auf 18 waere ein Datenbank-Major, kein CVE-Schritt.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2023-45853",
"CVE-2025-68121",
"CVE-2025-7458",
"CVE-2026-13221",
"CVE-2026-42496",
"CVE-2026-6653",
"CVE-2026-8376"
]
},
{
"ziel": "prometheuscommunity/postgres-exporter:v0.18.1",
"art": "hingenommen",
"grund": "Beiwagen im ESS-Postgres, Fassung vom Chart 26.8.0 gesetzt. GEMESSEN: v0.20.1 traegt 0 CRITICAL - die Fassung hier zu ueberschreiben hiesse aber, an einer Stelle vom Chart abzuweichen, die der naechste Chart-Sprung wieder einholt. Beim naechsten ESS-Sprung pruefen, ob er sie mitbringt.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "rohana.axion1337.de/sorb/axion-backup:v1",
"art": "hingenommen",
"grund": "Vorgaengerfassung des eigenen Backup-Images, absichtlich in der Zielmenge: sie ist das Rueckfallziel zu v2 und wird deshalb mitgeprueft, obwohl sie nicht laeuft. Derselbe Go-stdlib-Befund wie in v2; faellt mit dem naechsten Neubau weg.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "rohana.axion1337.de/sorb/axion-backup:v2",
"art": "geplant",
"grund": "Eigenes Image; ein Befund in der Go-stdlib. Faellt mit dem naechsten Neubau weg.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "rohana.axion1337.de/sorb/clamav-http-scanner:v1.0.0",
"art": "geplant",
"grund": "Eigenes Image auf Debian-Basis; die vier Befunde sind perl-base ohne Fix-Fassung. Faellt mit einem Neubau auf einer schlankeren Basis weg - dieselbe Umstellung, die die drei Python-Dienste des Betriebs-Stacks heute auf alpine gebracht hat.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2026-13221",
"CVE-2026-42496",
"CVE-2026-8376"
]
}
]
}
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
# Trivy-Scan-Schleife (gitops#47, seit #0106 mit abgeleiteter Zielmenge).
#
# Die Ziele standen bis 2026-08-21 in cve/images.txt und wurden von Hand
# nachgezogen. Gemessen an dem Tag: 27 von 51 laufenden Images, also 52 % -
# nicht gescannt wurden unter anderem der Web-Client, den jeder Nutzer laedt,
# beide Traefik-Schichten und die Registry selbst. Gleichzeitig lief ein Scan
# gegen threadnet-web:v0.3.0, ein Image, das nirgends mehr laeuft.
#
# Seitdem schreibt der cve-exporter die Soll-Menge nach /targets/targets.txt
# (Quellen: Cluster-Metriken, cAdvisor des Betriebs-Hosts, Registry). Diese
# Datei wird NICHT von Hand gepflegt - siehe ADR-0026.
#
# Kein Abbruch bei Einzelfehlern: Ein nicht mehr pullbares Image darf den Rest
# nicht verhindern; der Exporter alarmiert ueber trivy_last_scan_timestamp,
# wenn ein Report veraltet.
set -u
# Ueber die Umgebung ueberschreibbar - der Exporter fuehrt RESULTS_DIR
# bereits so, und test_scan_loop.sh laedt diese Datei damit.
RESULTS="${RESULTS_DIR:-/results}"
TARGETS="${TARGETS_FILE:-/targets/targets.txt}"
INTERVAL="${SCAN_INTERVAL_SECONDS:-86400}"
# ⚠️ Kurze Wartezeit, wenn die Zielliste (noch) fehlt: Beim ersten Start nach
# einem Ausrollen schreibt der Exporter sie erst beim naechsten Scrape. Mit dem
# vollen 24h-Intervall stuende der Scanner deswegen einen Tag still.
WARTEZEIT="${WARTE_INTERVAL_SECONDS:-60}"
# Trivy ist im Test durch eine Attrappe ersetzbar; hier der echte Aufruf.
scanne() { trivy image --scanners vuln --format json --output "$2" "$1"; }
runde() {
# ⚠️ Ohne brauchbare Zielliste wird NICHTS getan. Der Exporter schreibt sie
# nur, wenn mindestens eine Quelle geliefert hat - faellt die Herleitung
# ganz aus, bleibt die alte Datei stehen und diese Schleife arbeitet
# weiter mit dem letzten bekannten Stand, statt ins Leere zu laufen.
if [ ! -s "$TARGETS" ]; then
echo "warte: $TARGETS fehlt oder ist leer - keine Runde"
return 1
fi
anzahl=0
erwartet=$(mktemp)
while IFS= read -r img; do
case "$img" in ""|\#*) continue;; esac
anzahl=$((anzahl + 1))
safe=$(echo "$img" | tr '/:@' '___')
echo "$safe" >> "$erwartet"
echo "scan: $img"
scanne "$img" "$RESULTS/$safe.json.tmp" \
&& mv "$RESULTS/$safe.json.tmp" "$RESULTS/$safe.json" \
|| echo "FEHLER bei $img (Report bleibt auf altem Stand)"
done < "$TARGETS"
# Berichte ohne Ziel entfernen (#0106, Kriterium 3).
#
# ⚠️ WARUM DAS NOETIG IST: Der Exporter liest JEDE *.json in /results und
# nimmt das Ziel aus Trivys ArtifactName. Ein Bericht, dessen Image aus der
# Soll-Menge gefallen ist, meldet sonst weiter - genau der Zustand vom
# 2026-08-20, als der Security-Raum HIGH-CVEs zu threadnet-web:v0.3.0 meldete,
# das nirgends mehr lief. Rauschen, das wie Signal aussieht.
#
# ⚠️ WARUM ES HIER STEHT UND NICHT WEITER OBEN: Die Wache am Schleifenanfang
# hat bereits sichergestellt, dass eine nicht-leere Zielliste vorliegt. Ohne
# sie wird die Runde ausgesetzt - und damit auch nichts geloescht. Sonst
# raeumte ein Neustart waehrend eines Prometheus-Ausfalls den gesamten
# Bestand ab, und der Verlust faellt erst beim naechsten Ernstfall auf.
entfernt=0
for bericht in "$RESULTS"/*.json; do
[ -e "$bericht" ] || continue
name=$(basename "$bericht" .json)
# Host-rootfs-Scans (Ausbaustufe, Dateiname traegt __host__) stammen nicht
# aus dieser Zielliste und werden deshalb nicht danach beurteilt.
case "$name" in *__host__*) continue;; esac
if ! grep -qxF "$name" "$erwartet"; then
echo "verwaist, entfernt: $name"
rm -f "$bericht"
entfernt=$((entfernt + 1))
fi
done
rm -f "$erwartet"
echo "runde fertig: ${anzahl} Ziele, ${entfernt} verwaiste Berichte entfernt"
}
# ⚠️ Ladewache: test_scan_loop.sh laedt diese Datei, um die ECHTE runde() zu
# pruefen statt eine nachgebaute. Ohne die Wache liefe beim Laden die
# Endlosschleife an, und der Test haette einen Nachbau geprueft - also den
# Nachbau, nicht die Sache.
[ "${SCAN_LOOP_NUR_LADEN:-0}" = "1" ] && return 0
while true; do
# Ausdruecklich als if, nicht als `runde || sleep A && sleep B`: Das gruppiert
# links-assoziativ zu `(runde || sleep A) && sleep B` und schliefe bei
# fehlender Zielliste erst die Wartezeit UND danach das volle Intervall -
# also doch einen Tag, genau das, was die Wartezeit verhindern soll.
if runde; then
sleep "$INTERVAL"
else
sleep "$WARTEZEIT"
fi
done
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
# Soll-Menge der CVE-Scan-Ziele (#0106, ADR-0026): abgeleitet statt gepflegt.
#
# Vorher stand die Zielliste in cve/images.txt und wurde von Hand nachgezogen.
# Am 2026-08-21 gemessen: 27 von 51 laufenden Images, also 52 % Deckung - nicht
# gescannt wurden unter anderem der Web-Client, den jeder Nutzer laedt, beide
# Traefik-Schichten und die Registry selbst. Niemand hatte einen Fehler gemacht;
# die Pflege haengt an keinem Schritt, den ein Ausrollen ohnehin durchlaeuft.
#
# Drei Quellen, alle ohne neue Zugangsdaten und ohne neuen Netzweg:
# cluster - kube_pod_container_info (Prometheus)
# operating - container_last_seen{job=operating_*} (Prometheus, cAdvisor)
# registry - rohana /v2, anonymer Token-Tanz
#
# ⚠️ WARUM DIE FEHLER-BEHANDLUNG HIER DER KERN IST: Faellt eine Quelle aus, wird
# die Soll-Menge KLEINER - und die Deckung saehe damit BESSER aus, nicht
# schlechter. Ein Ausfall der Herleitung ist von Erfolg also nicht zu
# unterscheiden, solange niemand die Frische je Quelle misst. Deshalb traegt
# Herleitung.fehler und Herleitung.stand je Quelle, und deshalb wird bei
# vollstaendigem Ausfall NICHTS geschrieben (siehe cve-exporter.py).
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from typing import NamedTuple, Optional
# --- Quellen-Ausdruecke ------------------------------------------------------
# Beide zaehlen nur, um die Beschriftung `image` zu bekommen; der Wert ist egal.
# ⚠️ image_spec MUSS mit abgefragt werden. Ist ein Container per Digest
# festgenagelt (`repo:tag@sha256:...`, wie der config-reloader im alloy-Chart
# ab 1.x), traegt `image` nur noch den nackten `sha256:...` - als Scanziel
# unbrauchbar. Die vollstaendige Referenz steht dann allein in `image_spec`.
# Gefunden am 2026-08-21, eine Stunde nachdem der Chart-Sprung genau so einen
# Beiwagen hereingebracht hatte: ohne diesen Zusatz waere er still aus der
# Zielmenge gefallen - dieselbe Luecke, gegen die #0106 gebaut wurde.
AUSDRUCK_CLUSTER = "count by (image_spec, image) (kube_pod_container_info)"
# ⚠️ job=operating_cadvisor grenzt gegen gameserver_cadvisor ab (20 Images):
# game-operating liegt ausserhalb des Auftrags. Ein Beschriftungsvergleich,
# keine Ausschlussliste - eine Liste haette denselben Verfall wie images.txt.
AUSDRUCK_OPERATING = (
'count by (image) (container_last_seen{job="operating_cadvisor",image!=""})'
)
# Tags, die nie ausgeliefert werden - Bau-Artefakte der CI.
TAG_PRAEFIXE_IGNORIEREN = ("sha-", "latest")
QUELLEN = ("cluster", "operating", "registry")
# --- Normalisierung ----------------------------------------------------------
def normalisiere(ref: str) -> Optional[str]:
"""Bringt eine Image-Referenz auf eine vergleichbare Form.
kube_pod_container_info liefert voll qualifiziert
(`docker.io/library/postgres:17-alpine`), cAdvisor und die alte Liste oft
kurz (`postgres:17-alpine`). Ohne diesen Schritt meldet jeder Abgleich
Scheinluecken - beim ersten Handvergleich am 2026-08-21 war das nur deshalb
folgenlos, weil die gemischten Formen zufaellig nicht kollidierten.
Gibt None zurueck, wenn die Referenz kein Ziel sein kann. Insbesondere
wird ein fehlender Tag NICHT zu `:latest` ergaenzt - das erzeugte ein Ziel,
das es so nicht gibt, und genau davon wollen wir weg.
"""
ref = (ref or "").strip()
if not ref:
return None
# ⚠️ Nackter Digest ohne Repository (`sha256:<64 hex>`): kein Ziel, sondern
# das, was uebrig bleibt, wenn die Referenz verloren gegangen ist. Ohne
# diese Zeile rutscht er als Repo "sha256" mit Tag "<hex>" durch, landet in
# targets.txt, laesst sich nicht ziehen - und cve_targets_missing zaehlt
# dauerhaft eine Luecke, die in Wahrheit woanders sitzt.
if ref.startswith("sha256:") and "/" not in ref:
return None
# Digest abtrennen, Tag behalten: `foo:v1@sha256:...` -> `foo:v1`
if "@" in ref:
ref = ref.split("@", 1)[0]
teile = ref.split("/", 1)
if len(teile) == 2 and ("." in teile[0] or ":" in teile[0] or teile[0] == "localhost"):
registry, rest = teile[0], teile[1]
else:
registry, rest = None, ref
# Docker Hub ist der implizite Standard - beide Schreibweisen sind dasselbe.
if registry in ("docker.io", "index.docker.io"):
registry = None
if registry is None and rest.startswith("library/"):
rest = rest[len("library/") :]
# Tag pruefen: der Doppelpunkt muss im LETZTEN Pfadteil stehen, sonst ist es
# ein Registry-Port (`myreg:5000/foo`).
if ":" not in rest.rsplit("/", 1)[-1]:
return None
return rest if registry is None else f"{registry}/{rest}"
# --- Prometheus --------------------------------------------------------------
def promql(basis_url: str, ausdruck: str, timeout: float = 10.0) -> list:
"""Eine Instant-Query. Wirft bei allem, was nicht sauber 'success' ist."""
url = basis_url.rstrip("/") + "/api/v1/query?" + urllib.parse.urlencode(
{"query": ausdruck}
)
with urllib.request.urlopen(url, timeout=timeout) as antwort:
d = json.load(antwort)
if d.get("status") != "success":
raise RuntimeError(f"Prometheus: {d.get('status')} {d.get('error', '')}".strip())
return d.get("data", {}).get("result", [])
def _images_aus_ergebnis(ergebnis: list) -> set:
ziele = set()
for reihe in ergebnis:
m = reihe.get("metric", {})
# image_spec zuerst: es traegt die Referenz so, wie sie im Manifest
# steht. `image` faellt bei Digest-Festnagelung auf den nackten
# sha256 zurueck. cAdvisor kennt image_spec nicht - daher der Rueckfall.
n = normalisiere(m.get("image_spec") or "") or normalisiere(m.get("image", ""))
if n:
ziele.add(n)
return ziele
def ziele_cluster(basis_url: str, timeout: float = 10.0) -> set:
return _images_aus_ergebnis(promql(basis_url, AUSDRUCK_CLUSTER, timeout))
def ziele_operating(basis_url: str, timeout: float = 10.0) -> set:
return _images_aus_ergebnis(promql(basis_url, AUSDRUCK_OPERATING, timeout))
# --- Registry (OCI Distribution v2) -----------------------------------------
# Der Token-Tanz ist Standard und anonym moeglich: /v2/ antwortet immer 401 mit
# einem realm-Verweis, der Client holt dort einen Token und wiederholt. Trivy
# macht genau das beim Ziehen - am 2026-08-01 in der AAR schon gemessen
# ("zieht anonym, keine Credentials noetig"), am 2026-08-21 erneut bestaetigt,
# diesmal auch fuer Katalog und Tag-Listen.
#
# ⚠️ Giteas eigene API (/api/v1/packages) waere bequemer - sie liefert
# created_at je Fassung in einem Aufruf -, verlangt aber einen Token. Das haette
# die Randbedingung "keine neuen Zugangsdaten" gebrochen, deshalb der Umweg
# ueber Manifest + Config-Blob.
MANIFEST_TYPEN = ",".join(
[
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
]
)
def _hole_json(url: str, token: str = "", accept: str = "", timeout: float = 15.0):
anfrage = urllib.request.Request(url)
if token:
anfrage.add_header("Authorization", f"Bearer {token}")
if accept:
anfrage.add_header("Accept", accept)
with urllib.request.urlopen(anfrage, timeout=timeout) as antwort:
return json.load(antwort)
def registry_token(registry: str, scope: str, timeout: float = 15.0) -> str:
url = (
f"https://{registry}/v2/token?"
+ urllib.parse.urlencode({"service": "container_registry", "scope": scope})
)
return _hole_json(url, timeout=timeout).get("token", "")
def registry_repos(registry: str, timeout: float = 15.0) -> list:
token = registry_token(registry, "registry:catalog:*", timeout)
d = _hole_json(f"https://{registry}/v2/_catalog?n=1000", token, timeout=timeout)
return list(d.get("repositories") or [])
def registry_tags(registry: str, repo: str, token: str = "", timeout: float = 15.0) -> list:
token = token or registry_token(registry, f"repository:{repo}:pull", timeout)
d = _hole_json(f"https://{registry}/v2/{repo}/tags/list", token, timeout=timeout)
return list(d.get("tags") or [])
def tag_erstellt(
registry: str, repo: str, tag: str, token: str = "", timeout: float = 15.0
) -> Optional[datetime]:
"""Erstellzeitpunkt eines Tags: Manifest -> ggf. Index-Eintrag -> Config-Blob.
Nach dem NAMEN zu sortieren waere falsch: `v0.10.0` steht lexikografisch
vor `v0.9.0`. Massgeblich ist der Zeitstempel.
"""
token = token or registry_token(registry, f"repository:{repo}:pull", timeout)
basis = f"https://{registry}/v2/{repo}"
manifest = _hole_json(f"{basis}/manifests/{tag}", token, MANIFEST_TYPEN, timeout)
# Multi-Arch: der Index verweist auf Einzelmanifeste - eines genuegt, die
# Erstellzeit unterscheidet sich zwischen den Architekturen nicht relevant.
if "manifests" in manifest and manifest.get("manifests"):
digest = manifest["manifests"][0]["digest"]
manifest = _hole_json(f"{basis}/manifests/{digest}", token, MANIFEST_TYPEN, timeout)
digest = (manifest.get("config") or {}).get("digest")
if not digest:
return None
config = _hole_json(f"{basis}/blobs/{digest}", token, timeout=timeout)
roh = config.get("created")
if not roh:
return None
try:
return datetime.fromisoformat(roh.replace("Z", "+00:00"))
except ValueError:
return None
def ziele_registry(registry: str, je_repo: int = 3, laufend: Optional[set] = None,
timeout: float = 15.0) -> set:
"""Die juengsten `je_repo` Fassungen jedes Repos.
Reichweite laut Gate 1 (Entscheidung sorb, Option C): Bestand plus das, was
ein Rollback realistisch treffen kann. Ein Rollback-Ziel mit bekannter
Luecke ist der einzige Fall, in dem ein nicht laufendes Image betrieblich
zaehlt - bei v0.1.0 rollt niemand mehr zurueck.
EINENGUNG (#0051, Entscheidung sorb 2026-08-21): Beruecksichtigt werden nur
Repos, von denen gerade MINDESTENS EIN Tag laeuft. Ohne diese Bedingung
kamen reine Bau-Artefakte in die Zielmenge - `element-desktop-build` und
`windows-vm` trugen zusammen 61 CRITICAL, davon 1 behebbar. Befunde ueber
Images, die niemand betreibt, sind Rauschen, das wie Signal aussieht.
Die Einengung ist ABGELEITET, nicht gepflegt: `laufend` ist dieselbe Menge,
die die Herleitung ohnehin bildet. Keine zweite Liste (ADR-0026).
"""
ziele = set()
for repo in registry_repos(registry, timeout):
if laufend is not None and not any(
z.startswith(f"{registry}/{repo}:") for z in laufend
):
# Kein Tag dieses Repos laeuft - Bau-Artefakt, kein Rollback-Ziel.
continue
token = registry_token(registry, f"repository:{repo}:pull", timeout)
kandidaten = [
t
for t in registry_tags(registry, repo, token, timeout)
if not t.startswith(TAG_PRAEFIXE_IGNORIEREN)
]
datiert = []
for tag in kandidaten:
try:
wann = tag_erstellt(registry, repo, tag, token, timeout)
except (urllib.error.URLError, OSError, ValueError, KeyError):
# Ein einzelner unlesbarer Tag darf das Repo nicht kosten.
wann = None
if wann is not None:
datiert.append((wann, tag))
datiert.sort(reverse=True)
for _, tag in datiert[:je_repo]:
n = normalisiere(f"{registry}/{repo}:{tag}")
if n:
ziele.add(n)
return ziele
# --- Herleitung --------------------------------------------------------------
class Herleitung(NamedTuple):
ziele: set
je_quelle: dict # Quelle -> set
fehler: dict # Quelle -> Meldung (nur bei Ausfall)
stand: dict # Quelle -> unix-ts des letzten Erfolgs
@property
def vollstaendig(self) -> bool:
return not self.fehler
@property
def brauchbar(self) -> bool:
"""Mindestens eine Quelle hat geliefert.
Nur dann darf targets.txt ueberschrieben und duerfen Berichte
geloescht werden. Sonst raeumt ein Neustart waehrend eines
Prometheus-Ausfalls den gesamten Bestand ab.
"""
return bool(self.ziele)
def herleiten(cfg: dict, vorheriger_stand: Optional[dict] = None,
vorherige_ziele: Optional[dict] = None) -> Herleitung:
"""Fragt alle drei Quellen. Ein Ausfall kostet nur die eigene Quelle.
REIHENFOLGE IST BEDEUTSAM: Die Registry-Auswahl braucht den laufenden
Bestand, um Bau-Artefakte auszuschliessen (#0051). Faellt der Bestand aus,
faellt die Registry-Auswahl mit - und das ist richtig so: Eine
Registry-Auswahl ohne Bestandswissen koennte nicht unterscheiden, was ein
Rollback-Ziel ist und was ein Bau-Artefakt.
"""
stand = dict(vorheriger_stand or {})
vorher = dict(vorherige_ziele or {})
je_quelle, fehler = {}, {}
jetzt = time.time()
def versuche(name, hole):
if not cfg.get(f"{name}_an", True):
je_quelle[name] = set()
return
try:
neu = hole()
except Exception as e: # noqa: BLE001 - bewusst breit
je_quelle[name] = set(vorher.get(name, ()))
fehler[name] = f"{type(e).__name__}: {e}"
return
# ⚠️ EINE ANTWORT KANN ERFOLGREICH UND TROTZDEM WERTLOS SEIN.
# Am 2026-08-23 hat der k3s-Neustart kube-state-metrics und Alloy
# lahmgelegt; `kube_pod_container_info` war in Prometheus daraufhin
# LEER. Die Abfrage lief sauber durch, lieferte null Zeilen - und
# galt als Erfolg. Damit fiel die Cluster-Quelle von 39 Zielen auf 0,
# die Gesamtmenge von 54 auf 12, der Scanner loeschte 42 Berichte, und
# das Dashboard meldete Deckung 1,0. Keine der drei Regeln schlug an:
# die Menge war nicht leer, und der Zeitstempel war frisch.
#
# Eine Quelle, die schon einmal geliefert hat und jetzt NICHTS liefert,
# gilt deshalb als Ausfall: alter Stand bleibt stehen, der Zeitstempel
# altert, CveTargetSourceStale greift. Lieber eine veraltete Menge als
# eine, die stillschweigend Ziele fallen laesst.
if not neu and vorher.get(name):
je_quelle[name] = set(vorher[name])
fehler[name] = (f"leere Antwort trotz {len(vorher[name])} Zielen beim "
f"letzten Mal - alter Stand behalten")
return
je_quelle[name] = neu
stand[name] = jetzt
versuche("cluster", lambda: ziele_cluster(cfg["prometheus"], cfg["timeout"]))
versuche("operating", lambda: ziele_operating(cfg["prometheus"], cfg["timeout"]))
laufend = je_quelle.get("cluster", set()) | je_quelle.get("operating", set())
if laufend:
versuche("registry", lambda: ziele_registry(
cfg["registry"], cfg["je_repo"], laufend, cfg["timeout"]))
else:
# Ohne Bestandswissen keine Registry-Auswahl - lieber eine kleinere
# Menge als eine, die Bau-Artefakte mitschleppt. Als Fehler gemeldet,
# damit es nicht wie Erfolg aussieht.
je_quelle["registry"] = set()
if "registry" not in fehler:
fehler["registry"] = "uebersprungen: kein laufender Bestand bekannt"
ziele = set().union(*je_quelle.values()) if je_quelle else set()
return Herleitung(ziele=ziele, je_quelle=je_quelle, fehler=fehler, stand=stand)
def cfg_aus_umgebung() -> dict:
return {
"prometheus": os.environ.get("PROMETHEUS_URL", "http://prometheus:9090"),
"registry": os.environ.get("REGISTRY_HOST", "rohana.axion1337.de"),
"je_repo": int(os.environ.get("REGISTRY_TAGS_JE_REPO", "3")),
"timeout": float(os.environ.get("QUELLEN_TIMEOUT", "15")),
"cluster_an": os.environ.get("QUELLE_CLUSTER", "1") != "0",
"operating_an": os.environ.get("QUELLE_OPERATING", "1") != "0",
"registry_an": os.environ.get("QUELLE_REGISTRY", "1") != "0",
}
if __name__ == "__main__":
# Handlauf zur Uebergabe: zeigt die Mengen, ohne etwas zu schreiben.
h = herleiten(cfg_aus_umgebung())
for q in QUELLEN:
print(f"{q:10} {len(h.je_quelle.get(q, ())):3} {h.fehler.get(q, '')}")
print(f"{'gesamt':10} {len(h.ziele):3}")
for z in sorted(h.ziele):
print(" ", z)
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
# Zusicherungen fuer die Entscheidungen zu CRITICAL-Befunden (#0051,
# Abnahmekriterien 1 und 5). Stdlib, laeuft ohne Netz:
# python3 test_entscheidungen.py
#
# ⚠️ Geprueft wird die ECHTE cve-exporter.py, nicht eine Kopie ihrer Logik. Der
# Modulname traegt einen Bindestrich, deshalb der Umweg ueber importlib; der
# Serverstart steht dort unter __main__, damit dieser Import moeglich ist. Am
# 2026-08-21 hat ein nachgebauter Test fuer scan-loop.sh eine Sabotage gruen
# durchgelassen - dieselbe Falle soll hier nicht noch einmal stehen.
#
# ⚠️ Jede Zusicherung hat ihre GEGENPROBE: neben jedem "muss zaehlen" steht ein
# "darf NICHT zaehlen".
import importlib.util
import json
import os
import tempfile
import unittest
os.environ.setdefault("RESULTS_DIR", tempfile.mkdtemp())
os.environ.setdefault("STATE_FILE", os.path.join(tempfile.mkdtemp(), "s.json"))
_spec = importlib.util.spec_from_file_location(
"cve_exporter", os.path.join(os.path.dirname(__file__) or ".", "cve-exporter.py")
)
exporter = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(exporter)
ECHT = os.path.join(os.path.dirname(__file__) or ".", "entscheidungen.json")
def schreibe(doc):
fd, pfad = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as f:
json.dump(doc, f)
return pfad
GUELTIG = {
"eintraege": [
{"ziel": "coturn/coturn:4.17.2", "art": "hingenommen",
"grund": "neueste Fassung", "pruefen_am": "2026-11-21",
"cves": ["CVE-1", "CVE-2"]},
]
}
class LaderTest(unittest.TestCase):
def test_gueltige_datei_wird_zu_paaren(self):
getroffen, fehler = exporter.lade_entscheidungen(schreibe(GUELTIG))
self.assertIsNone(fehler)
self.assertEqual(set(getroffen), {("coturn/coturn:4.17.2", "CVE-1"),
("coturn/coturn:4.17.2", "CVE-2")})
self.assertEqual(getroffen[("coturn/coturn:4.17.2", "CVE-1")][0], "hingenommen")
def test_fehlende_datei_meldet_sich_und_entscheidet_nichts(self):
getroffen, fehler = exporter.lade_entscheidungen("/gibt/es/nicht.json")
self.assertEqual(getroffen, {})
self.assertEqual(fehler, "keine Datei")
def test_kaputte_datei_entscheidet_NICHTS_statt_alles(self):
# ⚠️ Die Richtung ist der Punkt: Eine unlesbare Datei muss dazu fuehren,
# dass alles als OFFEN zaehlt. Umgekehrt saehe ein Tippfehler in der
# Datei aus wie "alles entschieden" - und niemand merkte es.
fd, pfad = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as f:
f.write("{kein json")
getroffen, fehler = exporter.lade_entscheidungen(pfad)
self.assertEqual(getroffen, {})
self.assertIn("unlesbar", fehler)
# Gegenprobe: dieselbe Datei mit gueltigem Inhalt entscheidet sehr wohl.
self.assertEqual(len(exporter.lade_entscheidungen(schreibe(GUELTIG))[0]), 2)
def test_unbrauchbares_pruefdatum_verwirft_die_GANZE_datei(self):
doc = json.loads(json.dumps(GUELTIG))
doc["eintraege"][0]["pruefen_am"] = "irgendwann"
getroffen, fehler = exporter.lade_entscheidungen(schreibe(doc))
self.assertEqual(getroffen, {})
self.assertIn("Pruefdatum", fehler)
def test_eintrag_ohne_cve_kennungen_ist_ein_fehler(self):
# Eine Pauschale je Image ("alles an diesem Ziel") wuerde auch jeden
# NEUEN Befund verschlucken. Deshalb ist sie nicht ausdrueckbar.
doc = json.loads(json.dumps(GUELTIG))
doc["eintraege"][0]["cves"] = []
getroffen, fehler = exporter.lade_entscheidungen(schreibe(doc))
self.assertEqual(getroffen, {})
self.assertIn("CVE-Kennung", fehler)
def test_ziel_wird_normalisiert_wie_die_soll_menge(self):
# Sonst passt eine Entscheidung fuer "postgres:15-alpine" nicht auf
# einen Bericht, dessen ArtifactName "docker.io/library/postgres:..."
# lautet - und die Entscheidung liefe ins Leere, ohne aufzufallen.
doc = {"eintraege": [dict(GUELTIG["eintraege"][0],
ziel="docker.io/library/postgres:15-alpine")]}
getroffen, _ = exporter.lade_entscheidungen(schreibe(doc))
self.assertIn(("postgres:15-alpine", "CVE-1"), getroffen)
class RechnungTest(unittest.TestCase):
"""Die Zahl aus Kriterium 1: CRITICAL auf laufenden Zielen ohne Entscheidung."""
def rechne(self, kritisch, soll, getroffen):
laufend = {p for p in kritisch if p[0] in soll} if soll else set(kritisch)
return len(laufend - set(getroffen)), len(laufend & set(getroffen))
def test_entschiedenes_ist_nicht_offen_unentschiedenes_schon(self):
kritisch = {("a:1", "CVE-1"), ("a:1", "CVE-2")}
offen, entschieden = self.rechne(kritisch, {"a:1"}, {("a:1", "CVE-1"): ("x", 0)})
self.assertEqual((offen, entschieden), (1, 1))
def test_verwaister_bericht_haelt_die_zahl_NICHT_hoch(self):
# Nach einem Update liegt der alte Bericht noch da, bis die naechste
# Scan-Runde ihn entfernt. Zaehlte er mit, saehe ein erfolgreiches
# Update wie Stillstand aus.
kritisch = {("alt:1", "CVE-9"), ("neu:1", "CVE-1")}
offen, _ = self.rechne(kritisch, {"neu:1"}, {})
self.assertEqual(offen, 1)
# Gegenprobe: gehoert das alte Ziel noch zum Soll, zaehlt es sehr wohl.
offen_beide, _ = self.rechne(kritisch, {"neu:1", "alt:1"}, {})
self.assertEqual(offen_beide, 2)
def test_ausgefallene_soll_menge_gibt_KEINE_entwarnung(self):
# ⚠️ Beim Bauen zuerst falsch herum: eine leere Soll-Menge liess
# cve_critical_offen auf 0 fallen - waehrend die Herleitung ausgefallen
# war, also genau dann, wenn man sich am wenigsten darauf verlassen darf.
kritisch = {("a:1", "CVE-1"), ("b:1", "CVE-2")}
offen, _ = self.rechne(kritisch, set(), {})
self.assertEqual(offen, 2)
# Gegenprobe: mit bekannter Soll-Menge wird sehr wohl gefiltert.
offen_gefiltert, _ = self.rechne(kritisch, {"a:1"}, {})
self.assertEqual(offen_gefiltert, 1)
def test_entscheidung_fuer_ein_anderes_ziel_deckt_nicht(self):
# Derselbe CVE in einem anderen Image bleibt offen - genau der Grund,
# warum die Entscheidung am Paar (Ziel, CVE) haengt und nicht am CVE.
offen, _ = self.rechne({("b:1", "CVE-1")}, {"b:1"}, {("a:1", "CVE-1"): ("x", 0)})
self.assertEqual(offen, 1)
class CollectTest(unittest.TestCase):
"""⚠️ Diese Klasse prueft die ECHTE collect(), nicht die nachgebaute Rechnung
in RechnungTest. Der Unterschied ist an diesem Projekt schon einmal teuer
geworden: Am 2026-08-22 stand in collect() ein `and ziel in soll`, das den
Zaehler fuer verwaiste Entscheidungen blind machte, sobald ein Image ganz aus
dem Bestand fiel - und RechnungTest konnte das nicht sehen, weil dort die
Bedingung gar nicht stand.
"""
def laufe(self, berichte, entscheidungen, soll):
res = tempfile.mkdtemp()
for i, (ziel, cves) in enumerate(berichte):
json.dump({"ArtifactName": ziel, "ArtifactType": "container_image",
"Results": [{"Vulnerabilities": [
{"VulnerabilityID": c, "Severity": "CRITICAL", "PkgName": "p",
"InstalledVersion": "1", "FixedVersion": ""} for c in cves]}]},
open(os.path.join(res, f"b{i}.json"), "w"))
pfad = schreibe({"eintraege": entscheidungen})
alt_res, alt_ent = exporter.RESULTS, exporter.ENTSCHEIDUNGEN_FILE
exporter.RESULTS, exporter.ENTSCHEIDUNGEN_FILE = res, pfad
exporter.ziel_cache._letzte = exporter.ziel_herleitung.Herleitung(
ziele=set(soll), je_quelle={"cluster": set(soll)}, fehler={},
stand={"cluster": exporter.time.time()})
exporter.ziel_cache._geholt_um = exporter.time.time()
try:
zeilen = exporter.collect().splitlines()
finally:
exporter.RESULTS, exporter.ENTSCHEIDUNGEN_FILE = alt_res, alt_ent
self.zeilen = zeilen
return {z.split(" ")[0]: z.split(" ")[1] for z in zeilen if " " in z and "{" not in z}
E = [{"ziel": "a:1", "art": "hingenommen", "grund": "x" * 70,
"pruefen_am": "2099-01-01", "cves": ["CVE-1"]}]
def test_entschieden_und_offen_werden_getrennt_gezaehlt(self):
m = self.laufe([("a:1", ["CVE-1", "CVE-2"])], self.E, ["a:1"])
self.assertEqual(m["cve_critical_entschieden"], "1")
self.assertEqual(m["cve_critical_offen"], "1")
self.assertEqual(m["cve_entscheidungen_ohne_befund"], "0")
def test_entscheidung_zu_einem_VERSCHWUNDENEN_image_faellt_auf(self):
# Der Fall vom 2026-08-22: das Image ist ersetzt, das Ziel steht nicht
# mehr im Soll, der Bericht ist weg. Die Entscheidung ist Altpapier und
# muss sich melden - vorher meldete sie sich NICHT.
m = self.laufe([("b:1", [])], self.E, ["b:1"])
self.assertEqual(m["cve_entscheidungen_ohne_befund"], "1")
def test_gegenprobe_eine_gebrauchte_entscheidung_meldet_sich_nicht(self):
m = self.laufe([("a:1", ["CVE-1"])], self.E, ["a:1"])
self.assertEqual(m["cve_entscheidungen_ohne_befund"], "0")
self.assertEqual(m["cve_critical_offen"], "0")
def test_der_entschiedene_befund_bleibt_SICHTBAR(self):
# Der Kern der Entscheidung gegen eine Trivy-Ignore-Datei: entschieden
# heisst gezaehlt, nicht versteckt. Waere er unterdrueckt, fehlte diese
# Serie - und "0, weil behoben" saehe aus wie "0, weil weggeschaut".
m = self.laufe([("a:1", ["CVE-1"])], self.E, ["a:1"])
self.assertEqual(m["cve_critical_entschieden"], "1")
treffer = [z for z in self.zeilen if z.startswith("trivy_vuln_info{") and "CVE-1" in z]
self.assertEqual(len(treffer), 1, "der entschiedene Befund fehlt in trivy_vuln_info")
# Gegenprobe: ein Ziel ohne Befund erzeugt auch keine Serie.
self.laufe([("a:1", [])], self.E, ["a:1"])
self.assertFalse([z for z in self.zeilen if z.startswith("trivy_vuln_info{")])
class EchteDateiTest(unittest.TestCase):
def setUp(self):
self.getroffen, self.fehler = exporter.lade_entscheidungen(ECHT)
with open(ECHT) as f:
self.doc = json.load(f)
def test_sie_laedt_ueberhaupt(self):
self.assertIsNone(self.fehler)
self.assertGreater(len(self.getroffen), 0)
def test_jeder_eintrag_traegt_eine_begruendung_die_etwas_sagt(self):
# Kriterium 1 verlangt eine Begruendung. "siehe oben" waere keine.
for e in self.doc["eintraege"]:
self.assertGreaterEqual(len(e.get("grund", "")), 60, e["ziel"])
def test_jeder_eintrag_traegt_ein_pruefdatum(self):
# Kriterium 5: kein Suppress ohne Ablauf.
for e in self.doc["eintraege"]:
self.assertRegex(e.get("pruefen_am", ""), r"^\d{4}-\d{2}-\d{2}$", e["ziel"])
def test_nur_bekannte_arten(self):
for e in self.doc["eintraege"]:
self.assertIn(e.get("art"), ("hingenommen", "geplant"), e["ziel"])
def test_keine_doppelten_paare(self):
paare = [(e["ziel"], c) for e in self.doc["eintraege"] for c in e["cves"]]
self.assertEqual(len(paare), len(set(paare)))
if __name__ == "__main__":
unittest.main(verbosity=2)
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
# Zusicherungen fuer scan-loop.sh (#0106), ohne Trivy und ohne Netz:
# sh cve/test_scan_loop.sh
#
# Gate 3 hatte diese Faelle bei test_targets.py vorgesehen. Sie stehen hier,
# weil die Logik in POSIX-Shell lebt - ein Python-Test haette sie nachgebaut
# statt geprueft, und ein nachgebauter Test prueft den Nachbau.
#
# ⚠️ Der gefaehrliche Fall ist nicht "loescht zu wenig", sondern "loescht zu
# viel": Ohne Zielliste darf NICHTS entfernt werden, sonst raeumt ein Neustart
# waehrend eines Prometheus-Ausfalls den gesamten Bestand ab. Deshalb steht zu
# jeder Zusicherung ihre Gegenprobe.
set -u
FEHLER=0
ARBEIT=$(mktemp -d)
trap 'rm -rf "$ARBEIT"' EXIT
pruefe() { # name erwartet ist
if [ "$2" = "$3" ]; then
echo " ok $1"
else
echo " FEHL $1 — erwartet '$2', bekommen '$3'"
FEHLER=$((FEHLER + 1))
fi
}
# ⚠️ Die ECHTE runde() wird geladen, nicht nachgebaut. Ein nachgebauter Test
# prueft den Nachbau: er kann gruen bleiben, waehrend die ausgelieferte Datei
# kaputt ist. Die Ladewache in scan-loop.sh verhindert, dass dabei die
# Endlosschleife anlaeuft.
ARBEIT_RESULTS="$ARBEIT/results"
ARBEIT_TARGETS="$ARBEIT/targets.txt"
RESULTS_DIR="$ARBEIT_RESULTS"
TARGETS_FILE="$ARBEIT_TARGETS"
SCAN_LOOP_NUR_LADEN=1
export RESULTS_DIR TARGETS_FILE
. "$(dirname "$0")/scan-loop.sh"
# Trivy durch eine Attrappe ersetzen - die einzige Stelle, die Netz braeuchte.
scanne() { : > "$2"; }
# Aus der ECHTEN Ausgabe lesen, statt eine eigene Form zu erwarten.
entfernt_aus() { echo "$1" | sed -n "s/.*, \([0-9]*\) verwaiste.*/\1/p" | tail -1; }
ausgesetzt_aus() { echo "$1" | grep -q "^warte:" && echo AUSGESETZT || echo GELAUFEN; }
mkdir -p "$ARBEIT_RESULTS"
echo "1) Ohne Zielliste wird NICHTS geloescht (der gefaehrliche Fall)"
: > "$ARBEIT/results/altbestand.json"
: > "$ARBEIT/results/zweiter.json"
rm -f "$ARBEIT_TARGETS"
ergebnis=$(runde)
pruefe "Runde ausgesetzt" "AUSGESETZT" "$(ausgesetzt_aus "$ergebnis")"
pruefe "Berichte unberuehrt" "2" "$(ls -1 "$ARBEIT_RESULTS" | wc -l | tr -d ' ')"
echo "2) Leere Zielliste ebenso"
: > "$ARBEIT_TARGETS"
ergebnis=$(runde)
pruefe "Runde ausgesetzt" "AUSGESETZT" "$(ausgesetzt_aus "$ergebnis")"
pruefe "Berichte unberuehrt" "2" "$(ls -1 "$ARBEIT_RESULTS" | wc -l | tr -d ' ')"
echo "3) Mit Zielliste: verwaiste Berichte verschwinden, neue entstehen"
printf '# Kopfzeile\npostgres:17-alpine\nrohana.axion1337.de/sorb/threadnet-web:v0.6.0\n' > "$ARBEIT_TARGETS"
ergebnis=$(runde)
pruefe "zwei verwaiste entfernt" "2" "$(entfernt_aus "$ergebnis")"
pruefe "genau die zwei Ziele bleiben" "2" "$(ls -1 "$ARBEIT_RESULTS" | wc -l | tr -d ' ')"
pruefe "altbestand ist fort" "" "$(ls "$ARBEIT_RESULTS" | grep altbestand || true)"
pruefe "v0.6.0 ist da" "rohana.axion1337.de_sorb_threadnet-web_v0.6.0.json" \
"$(ls "$ARBEIT_RESULTS" | grep v0.6.0)"
echo "4) Der Fall vom 2026-08-20: v0.3.0 faellt aus der Menge und verstummt"
: > "$ARBEIT/results/rohana.axion1337.de_sorb_threadnet-web_v0.3.0.json"
ergebnis=$(runde)
pruefe "v0.3.0 entfernt" "1" "$(entfernt_aus "$ergebnis")"
pruefe "v0.3.0 ist fort" "" "$(ls "$ARBEIT_RESULTS" | grep 'v0.3.0' || true)"
echo "5) Host-Scans werden nicht nach dieser Liste beurteilt"
: > "$ARBEIT/results/etwas__host__cfgmon.json"
ergebnis=$(runde)
pruefe "nichts entfernt" "0" "$(entfernt_aus "$ergebnis")"
pruefe "Host-Bericht bleibt" "etwas__host__cfgmon.json" \
"$(ls "$ARBEIT_RESULTS" | grep __host__)"
echo
if [ "$FEHLER" -eq 0 ]; then
echo "ALLE ZUSICHERUNGEN GRUEN"
else
echo "$FEHLER ZUSICHERUNG(EN) ROT"
fi
exit "$FEHLER"
+466
View File
@@ -0,0 +1,466 @@
#!/usr/bin/env python3
# Zusicherungen fuer targets.py (#0106). Stdlib, laeuft ohne Netz:
# python3 test_targets.py
#
# Die Antworten sind AUFGEZEICHNET, nicht erfunden - abgenommen am 2026-08-21
# von der laufenden Anlage (Prometheus auf dem Betriebs-Host, rohana /v2).
#
# ⚠️ Jede Zusicherung hat ihre GEGENPROBE. Eine Pruefung, die nur je "gleich"
# gesagt hat, ist eine Vermutung - das ist in diesem Projekt die haeufigste
# Fehlerklasse ("meldet Erfolg, ist aber blind"), zuletzt belegt in der AAR vom
# 2026-08-01. Deshalb steht neben jedem "muss zusammenfallen" ein "darf NICHT
# zusammenfallen".
import unittest
from datetime import datetime, timezone
import targets
def ts(jahr, monat, tag):
return datetime(jahr, monat, tag, tzinfo=timezone.utc)
# --- Aufgezeichnete Antworten ------------------------------------------------
# Auszug aus `count by (image) (kube_pod_container_info)`, 2026-08-21.
PROM_CLUSTER = [
{"metric": {"image": "docker.io/library/postgres:17-alpine"}},
{"metric": {"image": "docker.io/rancher/mirrored-library-traefik:3.6.10"}},
{"metric": {"image": "rohana.axion1337.de/sorb/threadnet-web:v0.6.0"}},
{"metric": {"image": "oci.element.io/synapse:v1.151.0-ess.1"}},
]
# Auszug aus `container_last_seen{...}` - BEIDE Jobs, damit die Abgrenzung
# ueberhaupt etwas zu tun hat.
PROM_CADVISOR_GEMISCHT = [
{"metric": {"image": "gitea/gitea:1.27.0", "job": "operating_cadvisor"}},
{"metric": {"image": "traefik:v3.7.9", "job": "operating_cadvisor"}},
{"metric": {"image": "ghcr.io/coollabsio/coolify:4.3.9", "job": "gameserver_cadvisor"}},
{"metric": {"image": "redis:7-alpine", "job": "gameserver_cadvisor"}},
]
class Normalisierung(unittest.TestCase):
def test_docker_hub_schreibweisen_fallen_zusammen(self):
formen = [
"docker.io/library/postgres:17-alpine",
"index.docker.io/library/postgres:17-alpine",
"library/postgres:17-alpine",
"postgres:17-alpine",
]
ergebnisse = {targets.normalisiere(f) for f in formen}
self.assertEqual(ergebnisse, {"postgres:17-alpine"})
def test_gegenprobe_verschiedene_fassungen_fallen_NICHT_zusammen(self):
# Ohne diese Zusicherung koennte normalisiere() alles auf denselben Wert
# abbilden und der Test oben waere trotzdem gruen.
self.assertNotEqual(
targets.normalisiere("postgres:15-alpine"),
targets.normalisiere("postgres:17-alpine"),
)
self.assertNotEqual(
targets.normalisiere("rohana.axion1337.de/sorb/threadnet-web:v0.3.0"),
targets.normalisiere("rohana.axion1337.de/sorb/threadnet-web:v0.6.0"),
)
def test_fremde_registry_bleibt_unangetastet(self):
for ref in (
"rohana.axion1337.de/sorb/threadnet-web:v0.6.0",
"ghcr.io/fluxcd/source-controller:v1.8.2",
"quay.io/jetstack/cert-manager-webhook:v1.14.0",
):
self.assertEqual(targets.normalisiere(ref), ref)
def test_ohne_tag_wird_verworfen_und_NICHT_zu_latest(self):
for ref in ("postgres", "docker.io/library/postgres", "ghcr.io/a/b"):
self.assertIsNone(targets.normalisiere(ref))
def test_digest_wird_abgetrennt_tag_bleibt(self):
self.assertEqual(
targets.normalisiere("postgres:17-alpine@sha256:" + "ab" * 32),
"postgres:17-alpine",
)
# Nur Digest, kein Tag -> kein Ziel.
self.assertIsNone(targets.normalisiere("postgres@sha256:" + "ab" * 32))
def test_registry_mit_port_wird_nicht_als_tag_gelesen(self):
self.assertEqual(targets.normalisiere("reg.lan:5000/a/b:v1"), "reg.lan:5000/a/b:v1")
self.assertIsNone(targets.normalisiere("reg.lan:5000/a/b"))
def test_leeres_und_muell(self):
for ref in ("", " ", None):
self.assertIsNone(targets.normalisiere(ref))
class Quellentrennung(unittest.TestCase):
def setUp(self):
self._echt = targets.promql
def tearDown(self):
targets.promql = self._echt
def test_operating_nimmt_gameserver_nicht_mit(self):
# Der Ausdruck filtert serverseitig; hier wird geprueft, dass er den
# Filter ueberhaupt traegt - sonst faellt die Abgrenzung still weg.
self.assertIn('job="operating_cadvisor"', targets.AUSDRUCK_OPERATING)
gesehen = {}
def fake(basis, ausdruck, timeout=10.0):
gesehen["ausdruck"] = ausdruck
# Prometheus wuerde bereits gefiltert antworten - wir simulieren das,
# indem wir den Filter anwenden.
job = "operating_cadvisor" if 'job="operating_cadvisor"' in ausdruck else None
return [r for r in PROM_CADVISOR_GEMISCHT if r["metric"]["job"] == job]
targets.promql = fake
ziele = targets.ziele_operating("http://x")
self.assertEqual(ziele, {"gitea/gitea:1.27.0", "traefik:v3.7.9"})
def test_gegenprobe_ohne_filter_kaeme_gameserver_mit(self):
# Beweist, dass die Zusicherung oben ueberhaupt etwas prueft.
def fake_ohne_filter(basis, ausdruck, timeout=10.0):
return PROM_CADVISOR_GEMISCHT
targets.promql = fake_ohne_filter
ziele = targets.ziele_operating("http://x")
self.assertIn("redis:7-alpine", ziele)
def test_cluster_normalisiert_beim_einlesen(self):
targets.promql = lambda b, a, timeout=10.0: PROM_CLUSTER
ziele = targets.ziele_cluster("http://x")
self.assertIn("postgres:17-alpine", ziele)
self.assertIn("rancher/mirrored-library-traefik:3.6.10", ziele)
self.assertNotIn("docker.io/library/postgres:17-alpine", ziele)
class RegistryAuswahl(unittest.TestCase):
def setUp(self):
self._repos, self._tags, self._erstellt = (
targets.registry_repos,
targets.registry_tags,
targets.tag_erstellt,
)
targets.registry_token = lambda *a, **k: "egal"
def tearDown(self):
targets.registry_repos, targets.registry_tags, targets.tag_erstellt = (
self._repos,
self._tags,
self._erstellt,
)
def _stelle(self, tags, zeiten):
targets.registry_repos = lambda reg, timeout=15.0: ["sorb/x"]
targets.registry_tags = lambda reg, repo, token="", timeout=15.0: tags
targets.tag_erstellt = lambda reg, repo, tag, token="", timeout=15.0: zeiten.get(tag)
def test_juengste_drei_nach_ZEIT_nicht_nach_name(self):
# ⚠️ Der Kern: lexikografisch stuende v0.9.0 vor v0.10.0.
tags = ["v0.9.0", "v0.10.0", "v0.8.0", "v0.7.0"]
zeiten = {
"v0.7.0": ts(2026, 1, 1),
"v0.8.0": ts(2026, 2, 1),
"v0.9.0": ts(2026, 3, 1),
"v0.10.0": ts(2026, 4, 1),
}
self._stelle(tags, zeiten)
ziele = targets.ziele_registry("reg.example", je_repo=3)
self.assertEqual(
ziele,
{"reg.example/sorb/x:v0.10.0", "reg.example/sorb/x:v0.9.0", "reg.example/sorb/x:v0.8.0"},
)
def test_gegenprobe_namenssortierung_haette_anders_gewaehlt(self):
# Belegt, dass der Test oben nicht zufaellig gruen ist: nach Namen
# sortiert faellt die JUENGSTE Fassung heraus und eine alte kommt mit.
tags = ["v0.9.0", "v0.10.0", "v0.8.0", "v0.7.0"]
nach_name = sorted(tags, reverse=True)[:3]
self.assertNotIn("v0.10.0", nach_name) # die juengste - waere verloren
self.assertIn("v0.7.0", nach_name) # die aelteste - waere mitgekommen
def test_bau_artefakte_fallen_raus(self):
tags = ["v1.0.0", "sha-13547676", "latest-ci", "latest"]
zeiten = {t: ts(2026, 5, 10) for t in tags}
self._stelle(tags, zeiten)
ziele = targets.ziele_registry("reg.example", je_repo=5)
self.assertEqual(ziele, {"reg.example/sorb/x:v1.0.0"})
def _stelle_repos(self, repos):
targets.registry_repos = lambda reg, timeout=15.0: repos
targets.registry_tags = lambda reg, repo, token="", timeout=15.0: ["v1.0.0"]
targets.tag_erstellt = lambda reg, repo, tag, token="", timeout=15.0: ts(2026, 5, 1)
def test_nur_repos_mit_laufendem_tag_zaehlen(self):
# ⚠️ Der Kern der Einengung (#0051): Von drei Repos laeuft nur eines.
# element-desktop-build und windows-vm sind Bau-Artefakte und trugen
# zusammen 61 CRITICAL, davon 1 behebbar.
self._stelle_repos(["sorb/threadnet-web", "sorb/element-desktop-build", "sorb/windows-vm"])
laufend = {"reg.example/sorb/threadnet-web:v0.6.0"}
ziele = targets.ziele_registry("reg.example", je_repo=3, laufend=laufend)
self.assertEqual(ziele, {"reg.example/sorb/threadnet-web:v1.0.0"})
def test_gegenprobe_ohne_einengung_kaemen_die_bau_artefakte_mit(self):
# Belegt, dass der Test darueber ueberhaupt etwas prueft: ohne
# Bestandswissen liefert dieselbe Funktion alle drei Repos.
self._stelle_repos(["sorb/threadnet-web", "sorb/element-desktop-build", "sorb/windows-vm"])
ziele = targets.ziele_registry("reg.example", je_repo=3, laufend=None)
self.assertEqual(len(ziele), 3)
self.assertIn("reg.example/sorb/windows-vm:v1.0.0", ziele)
def test_einengung_trifft_nur_das_repo_nicht_den_tag(self):
# Laeuft v0.6.0, ist v0.5.4 trotzdem ein gueltiges Rollback-Ziel -
# die Einengung schliesst REPOS aus, nicht alte Fassungen.
targets.registry_repos = lambda reg, timeout=15.0: ["sorb/threadnet-web"]
targets.registry_tags = lambda reg, repo, token="", timeout=15.0: ["v0.6.0", "v0.5.4"]
targets.tag_erstellt = lambda reg, repo, tag, token="", timeout=15.0: (
ts(2026, 8, 19) if tag == "v0.6.0" else ts(2026, 8, 17))
laufend = {"reg.example/sorb/threadnet-web:v0.6.0"}
ziele = targets.ziele_registry("reg.example", je_repo=3, laufend=laufend)
self.assertIn("reg.example/sorb/threadnet-web:v0.5.4", ziele)
def test_tag_ohne_zeitstempel_wird_uebersprungen_nicht_geraten(self):
tags = ["v1.0.0", "v2.0.0"]
zeiten = {"v1.0.0": ts(2026, 5, 10)} # v2.0.0 liefert None
self._stelle(tags, zeiten)
ziele = targets.ziele_registry("reg.example", je_repo=5)
self.assertEqual(ziele, {"reg.example/sorb/x:v1.0.0"})
class Ausfallverhalten(unittest.TestCase):
"""⚠️ Der eigentliche Kern (ADR-0026): Ein Ausfall der Herleitung darf nicht
wie vollstaendige Deckung aussehen."""
CFG = {
"prometheus": "http://x",
"registry": "reg.example",
"je_repo": 3,
"timeout": 1.0,
"cluster_an": True,
"operating_an": True,
"registry_an": True,
}
def setUp(self):
self._c, self._o, self._r = (
targets.ziele_cluster,
targets.ziele_operating,
targets.ziele_registry,
)
def tearDown(self):
targets.ziele_cluster, targets.ziele_operating, targets.ziele_registry = (
self._c,
self._o,
self._r,
)
def _stelle(self, cluster=None, operating=None, registry=None):
def macher(wert):
if isinstance(wert, Exception):
def f(*a, **k):
raise wert
return f
return lambda *a, **k: (wert or set())
targets.ziele_cluster = macher(cluster)
targets.ziele_operating = macher(operating)
targets.ziele_registry = macher(registry)
def test_alles_gut(self):
self._stelle({"a:1"}, {"b:1"}, {"c:1"})
h = targets.herleiten(self.CFG)
self.assertEqual(h.ziele, {"a:1", "b:1", "c:1"})
self.assertTrue(h.vollstaendig)
self.assertTrue(h.brauchbar)
self.assertEqual(set(h.stand), {"cluster", "operating", "registry"})
def test_eine_quelle_faellt_aus_die_anderen_liefern_weiter(self):
self._stelle({"a:1"}, OSError("Prometheus weg"), {"c:1"})
h = targets.herleiten(self.CFG)
self.assertEqual(h.ziele, {"a:1", "c:1"})
self.assertFalse(h.vollstaendig)
self.assertTrue(h.brauchbar)
self.assertIn("operating", h.fehler)
self.assertNotIn("operating", h.stand) # Frische bleibt aus
self.assertIn("cluster", h.stand)
def test_stand_der_ausgefallenen_quelle_bleibt_auf_dem_ALTEN_wert(self):
# Damit cve_target_source_stale altert, statt zu verschwinden.
self._stelle(OSError("weg"), {"b:1"}, {"c:1"})
h = targets.herleiten(self.CFG, vorheriger_stand={"cluster": 1000.0})
self.assertEqual(h.stand["cluster"], 1000.0)
self.assertGreater(h.stand["operating"], 1000.0)
def test_alle_quellen_weg_ergibt_leere_und_UNBRAUCHBARE_menge(self):
self._stelle(OSError("a"), OSError("b"), OSError("c"))
h = targets.herleiten(self.CFG)
self.assertEqual(h.ziele, set())
self.assertFalse(h.brauchbar) # -> nichts schreiben, nichts loeschen
self.assertEqual(set(h.fehler), {"cluster", "operating", "registry"})
def test_gegenprobe_leere_menge_ist_NICHT_dasselbe_wie_erfolg(self):
# Der Fall, der ohne diese Unterscheidung wie 100 % Deckung aussaehe.
#
# ⚠️ VERHALTEN GEAENDERT am 2026-08-21 (#0051): Antworten Cluster UND
# Betriebs-Host erfolgreich LEER, wird die Registry-Auswahl jetzt
# uebersprungen und als Fehler gemeldet. Frueher galt das als
# "vollstaendig, nur unbrauchbar". Der Grund fuer die Aenderung: Ohne
# laufenden Bestand kann die Registry-Auswahl Rollback-Ziele nicht von
# Bau-Artefakten unterscheiden - und laeuft wirklich nichts, ist das ein
# Ausfall und kein Normalzustand. Er soll deshalb als Fehler sichtbar
# sein, nicht nur an `brauchbar` haengen.
self._stelle(set(), set(), set())
h = targets.herleiten(self.CFG)
self.assertEqual(h.ziele, set())
self.assertFalse(h.brauchbar) # nicht verwertbar ...
self.assertFalse(h.vollstaendig) # ... UND es wird gesagt
self.assertIn("registry", h.fehler)
self.assertIn("kein laufender Bestand", h.fehler["registry"])
def test_registry_ohne_laufenden_bestand_wird_uebersprungen(self):
# Gegenprobe zur Zeile darueber: Mit Bestand laeuft die Registry sehr wohl.
self._stelle({"a:1"}, set(), {"reg/x:v1"})
h = targets.herleiten(self.CFG)
self.assertIn("reg/x:v1", h.ziele)
self.assertNotIn("registry", h.fehler)
class Deckungsrechnung(unittest.TestCase):
"""Die Mengenarithmetik, auf der die Kriterien 2 und 3 beruhen."""
def test_zahlen_der_handmessung_vom_2026_08_21(self):
soll = {f"i{n}:v1" for n in range(51)}
ist = {f"i{n}:v1" for n in range(27)} | {"alt:v1", "weg:v1"}
fehlend = soll - ist
verwaist = ist - soll
self.assertEqual(len(fehlend), 24)
self.assertEqual(len(verwaist), 2)
self.assertAlmostEqual(len(soll & ist) / len(soll), 27 / 51, places=4)
def test_gegenprobe_die_rechnung_kann_rot_werden(self):
soll = {"a:1", "b:1"}
ist = {"a:1", "b:1"}
self.assertEqual(len(soll - ist), 0)
ist_kaputt = {"a:1"}
self.assertEqual(len(soll - ist_kaputt), 1) # merkt es
class LeereAntwortIstKeinErfolg(unittest.TestCase):
"""⚠️ Nachgetragen am 2026-08-23, nachdem genau dieser Fall die Zielmenge
von 54 auf 12 fallen liess und der Scanner 42 Berichte loeschte.
Der k3s-Neustart legte kube-state-metrics und Alloy lahm; die
Prometheus-Abfrage lief daraufhin sauber durch und lieferte NULL Zeilen.
Das galt als Erfolg: Zeitstempel frisch, Menge nicht leer (der Betriebs-Host
lieferte ja), also schlug keine der drei Regeln an. Deckung 1,0, waehrend
drei Viertel des Bestands unbeobachtet waren.
"""
CFG = {"prometheus": "http://x", "registry": "r", "je_repo": 3, "timeout": 1}
def herleite(self, antworten, vorher=None, stand=None):
echt = (targets.ziele_cluster, targets.ziele_operating, targets.ziele_registry)
targets.ziele_cluster = lambda *a, **k: antworten["cluster"]
targets.ziele_operating = lambda *a, **k: antworten["operating"]
targets.ziele_registry = lambda *a, **k: antworten["registry"]
try:
return targets.herleiten(self.CFG, vorheriger_stand=stand, vorherige_ziele=vorher)
finally:
targets.ziele_cluster, targets.ziele_operating, targets.ziele_registry = echt
VORHER = {"cluster": {"a:1", "b:1"}, "operating": {"c:1"}, "registry": {"d:1"}}
def test_leere_antwort_behaelt_den_alten_stand(self):
h = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": {"d:1"}},
vorher=self.VORHER, stand={"cluster": 100.0})
self.assertEqual(h.je_quelle["cluster"], {"a:1", "b:1"}) # NICHT verloren
self.assertIn("cluster", h.fehler)
self.assertEqual(h.stand["cluster"], 100.0) # Zeitstempel altert
def test_gegenprobe_eine_ECHT_leere_erstmenge_ist_kein_fehler(self):
# Ohne Vorwissen ist eine leere Antwort schlicht eine leere Antwort -
# sonst koennte die Anlage nie sauber anlaufen.
h = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": {"d:1"}},
vorher=None, stand=None)
self.assertEqual(h.je_quelle["cluster"], set())
self.assertNotIn("cluster", h.fehler)
def test_gegenprobe_eine_gefuellte_antwort_ersetzt_den_alten_stand(self):
h = self.herleite({"cluster": {"neu:1"}, "operating": {"c:1"}, "registry": {"d:1"}},
vorher=self.VORHER, stand={"cluster": 100.0})
self.assertEqual(h.je_quelle["cluster"], {"neu:1"})
self.assertNotIn("cluster", h.fehler)
self.assertGreater(h.stand["cluster"], 100.0)
def test_ein_ausfall_verliert_die_ziele_der_quelle_NICHT(self):
def kaputt(*a, **k):
raise RuntimeError("Prometheus weg")
echt = targets.ziele_cluster
targets.ziele_cluster = kaputt
try:
h = targets.herleiten(self.CFG, vorheriger_stand={"cluster": 100.0},
vorherige_ziele=self.VORHER)
finally:
targets.ziele_cluster = echt
self.assertEqual(h.je_quelle["cluster"], {"a:1", "b:1"})
self.assertIn("cluster", h.fehler)
def test_die_gesamtmenge_bricht_dadurch_nicht_ein(self):
# Der eigentliche Schaden war die SUMME: 54 -> 12. Genau die darf nicht
# mehr passieren, solange die Quelle schon einmal geliefert hat.
h = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": set()},
vorher=self.VORHER, stand=None)
self.assertEqual(len(h.ziele), 4) # a,b,c,d - nichts verloren
h_ohne = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": set()},
vorher=None, stand=None)
self.assertEqual(len(h_ohne.ziele), 1) # Gegenprobe: ohne Vorwissen sehr wohl
class DigestFestgenagelt(unittest.TestCase):
"""⚠️ Nachgetragen am 2026-08-21, nachdem ein Chart-Sprung einen per Digest
festgenagelten Beiwagen hereingebracht hat (config-reloader im alloy-Chart
1.x). `kube_pod_container_info` fuehrt dessen `image` dann nur noch als
nackten sha256 - die brauchbare Referenz steht in `image_spec`. Ohne diese
Zusicherungen faellt so ein Container still aus der Zielmenge, und zwar
genau in der Herleitung, die gebaut wurde, damit nichts mehr still
herausfaellt.
"""
# Aufgezeichnet aus dem laufenden Cluster, 2026-08-21 nach dem Chart-Sprung.
ECHTE_ANTWORT = [
{"metric": {"image": "docker.io/grafana/alloy:v1.16.3",
"image_spec": "docker.io/grafana/alloy:v1.16.3"}},
{"metric": {"image": "sha256:c92477446e98f056925f69537521dab09b1c57f894bf258b3344f8bce723cdc7",
"image_spec": "quay.io/prometheus-operator/prometheus-config-reloader:v0.91.0@sha256:7d9e4eea5f1139e602508871f422b0116c60e87c662f3dcd234d5ab60cd0d8c1"}},
]
def test_der_beiwagen_landet_in_der_zielmenge(self):
ziele = targets._images_aus_ergebnis(self.ECHTE_ANTWORT)
self.assertIn("quay.io/prometheus-operator/prometheus-config-reloader:v0.91.0", ziele)
self.assertIn("grafana/alloy:v1.16.3", ziele)
def test_der_nackte_digest_landet_NICHT_darin(self):
# Gegenprobe zur Zusicherung darueber: Ohne die Abweisung waere er als
# Repo "sha256" mit Tag "<hex>" durchgerutscht - ein Ziel, das sich nie
# ziehen laesst, das aber dauerhaft als Deckungsluecke zaehlt.
ziele = targets._images_aus_ergebnis(self.ECHTE_ANTWORT)
self.assertEqual(len(ziele), 2)
self.assertFalse([z for z in ziele if z.startswith("sha256:")])
self.assertIsNone(targets.normalisiere("sha256:" + "c9" * 32))
# ... aber ein Digest MIT Repository bleibt ein gueltiges Ziel.
self.assertEqual(targets.normalisiere("quay.io/x/y:v1@sha256:abc"), "quay.io/x/y:v1")
def test_ohne_image_spec_wird_image_genommen(self):
# cAdvisor kennt image_spec nicht - der Rueckfall muss tragen.
ziele = targets._images_aus_ergebnis(
[{"metric": {"image": "docker.io/library/nginx:1.31.4-alpine"}}])
self.assertEqual(ziele, {"nginx:1.31.4-alpine"})
def test_die_abfrage_holt_image_spec_ueberhaupt(self):
# ⚠️ Die Auswertung oben kann image_spec nur lesen, wenn die Abfrage es
# mitliefert. Ohne diese Zeile waere alles darueber gruen und trotzdem
# wirkungslos - die Fehlerklasse "meldet Erfolg, ist aber blind".
self.assertIn("image_spec", targets.AUSDRUCK_CLUSTER)
if __name__ == "__main__":
unittest.main(verbosity=2)
+184 -17
View File
@@ -1,10 +1,15 @@
services: services:
prometheus: prometheus:
image: prom/prometheus:v3.3.1 image: prom/prometheus:v3.14.0
container_name: prometheus container_name: prometheus
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro # Verzeichnis- statt Einzeldatei-Mount: Docker haengt Einzeldatei-Mounts am
# Inode auf, und `git pull` ersetzt Dateien per Rename (neuer Inode) - der
# Container sah die Aenderung dann nie, waehrend SIGHUP brav Erfolg meldete.
# Verzeichnis-Mounts loesen bei jedem Zugriff ueber den Pfad auf.
# Config-Pfade bleiben unveraendert (--config.file zeigt weiter dorthin).
- ./prometheus:/etc/prometheus:ro
- prometheus_data:/prometheus - prometheus_data:/prometheus
command: command:
- '--config.file=/etc/prometheus/prometheus.yml' - '--config.file=/etc/prometheus/prometheus.yml'
@@ -12,38 +17,177 @@ services:
- '--storage.tsdb.retention.time=45d' - '--storage.tsdb.retention.time=45d'
- '--storage.tsdb.retention.size=15GB' - '--storage.tsdb.retention.size=15GB'
# Remote-Write-Receiver: k3s-Cluster und Matrix-Server pushen hierher. # Remote-Write-Receiver: k3s-Cluster und Matrix-Server pushen hierher.
# ACHTUNG: Port 9090 ist oeffentlich und ohne Auth erreichbar -- # ⚠️ Prometheus hat KEINE Authentisierung. Bis 2026-08-21 stand hier
# per Hetzner Cloud Firewall auf die Absender-IPs einschraenken! # "9090:9090", also 0.0.0.0 - oeffentlich erreichbar, abgesichert allein
# durch die Hetzner-Firewall. Seither an Adressen gebunden: der private
# vSwitch fuer die pushenden Absender, localhost fuer den Host selbst.
# Wer das zurueckdreht, macht die Zeitreihen wieder oeffentlich lesbar
# UND beschreibbar.
- '--web.enable-remote-write-receiver' - '--web.enable-remote-write-receiver'
ports: ports:
- "9090:9090" - "10.0.0.3:9090:9090"
- "127.0.0.1:9090:9090"
networks:
- traefik
alertmanager:
image: prom/alertmanager:v0.34.0
container_name: alertmanager
restart: unless-stopped
volumes:
# Verzeichnis-Mount, gleicher Grund wie bei Prometheus. Die beiden .py aus
# diesem Ordner liegen dadurch mit unter /etc/alertmanager - ungenutzt und
# harmlos, alertmanager laedt ausschliesslich --config.file.
- ./alertmanager:/etc/alertmanager:ro
- alertmanager_data:/alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
# bewusst kein oeffentlicher Port - nur Prometheus/Receiver im traefik-Netz
networks:
- traefik
# Alertmanager-Webhook -> Matrix (wartung-Raum), gleiche Machart wie
# maintenance-notify (gitops Issue #24). Secrets kommen aus .env.
matrix-alerts:
# 3.13-slim trug 4 CRITICAL (Debian-Basis, ohne Fix in der Fassung);
# 3.13-alpine gemessen am 2026-08-21: 0 CRITICAL, 2 HIGH. Alle drei
# Dienste hier laufen rein auf der stdlib, TLS gegen /etc/ssl/cert.pem
# im Image geprueft - kein Paket, das musl stoeren koennte.
image: python:3.13-alpine
container_name: matrix-alerts
restart: unless-stopped
environment:
- MATRIX_HOMESERVER=${MATRIX_ALERT_HOMESERVER}
- MATRIX_ROOM_ID=${MATRIX_ALERT_ROOM_ID}
- MATRIX_TOKEN=${MATRIX_ALERT_TOKEN}
# Raum-Routing (gitops#47): Alerts mit Label room=security -> Security-Raum
- MATRIX_ROOM_SECURITY=${MATRIX_RELEASE_ROOM_ID:-}
# Ohne das liegt der State unter /tmp im Writable Layer: der ueberlebt
# zwar ein "compose restart", aber kein "up -d", das den Container neu
# baut -- also genau jeden Deploy. Dann verlieren offene Alarme ihre
# Event-Zuordnung und loesen sich ueber den Fallback als separate
# Nachricht auf, statt die Firing-Nachricht abzuhaken.
- MATRIX_STATE_FILE=/state/matrix-alerts-state.json
volumes:
- ./alertmanager:/app:ro
- matrix_alerts_data:/state
command: python3 /app/matrix-alerts.py
networks:
- traefik
# Release-/Advisory-Watch (gitops#22): meldet neue Releases der Element-Stack-
# Upstreams in den Alerts-Raum (🚨 bei Security-Verdacht). Gleicher Bot/Raum
# wie matrix-alerts, eigener State (Erstlauf merkt nur, flutet nicht).
release-watch:
# 3.13-slim trug 4 CRITICAL (Debian-Basis, ohne Fix in der Fassung);
# 3.13-alpine gemessen am 2026-08-21: 0 CRITICAL, 2 HIGH. Alle drei
# Dienste hier laufen rein auf der stdlib, TLS gegen /etc/ssl/cert.pem
# im Image geprueft - kein Paket, das musl stoeren koennte.
image: python:3.13-alpine
container_name: release-watch
restart: unless-stopped
environment:
- MATRIX_HOMESERVER=${MATRIX_ALERT_HOMESERVER}
- MATRIX_ROOM_ID=${MATRIX_ALERT_ROOM_ID}
# CVE-/Release-Raum (gitops#47): leer lassen = Fallback Alerts-Raum
- MATRIX_RELEASE_ROOM_ID=${MATRIX_RELEASE_ROOM_ID:-}
- MATRIX_TOKEN=${MATRIX_ALERT_TOKEN}
volumes:
- ./alertmanager:/app:ro
- release_watch_data:/state
command: python3 /app/release-watch.py
networks:
- traefik
# CVE-Pipeline (gitops#47), Teil 1: Trivy scannt die real deployten Images
# (Ziele aus /targets/targets.txt - abgeleitet vom cve-exporter, NICHT von Hand
# pflegen: #0106 / ADR-0026)
cve-scan:
image: aquasec/trivy:0.74.0
container_name: cve-scan
restart: unless-stopped
entrypoint: ["/bin/sh", "/config/scan-loop.sh"]
volumes:
- ./cve:/config:ro
- cve_results:/results
- cve_trivy_cache:/root/.cache
# Ab Slice 3 die Quelle der Ziele; in Slice 1 nur eingehaengt, damit
# der Pfad existiert und beim Umschalten kein Rennen entsteht.
- cve_targets:/targets:ro
networks:
- traefik
# Teil 2: Reports -> Prometheus-Metriken (Schema + Pflichtfelder siehe gitops#47)
cve-exporter:
# 3.13-slim trug 4 CRITICAL (Debian-Basis, ohne Fix in der Fassung);
# 3.13-alpine gemessen am 2026-08-21: 0 CRITICAL, 2 HIGH. Alle drei
# Dienste hier laufen rein auf der stdlib, TLS gegen /etc/ssl/cert.pem
# im Image geprueft - kein Paket, das musl stoeren koennte.
image: python:3.13-alpine
container_name: cve-exporter
restart: unless-stopped
environment:
- RESULTS_DIR=/results
- STATE_FILE=/state/first-seen.json
# Soll-Menge der Scan-Ziele (#0106, ADR-0026): abgeleitet, nicht gepflegt.
# Der Exporter bildet sie, weil er fuer die Deckungsmetrik ohnehin BEIDE
# Mengen kennen muss - ein zweiter Ort waere eine zweite Wahrheit.
- TARGETS_FILE=/targets/targets.txt
- PROMETHEUS_URL=http://prometheus:9090
- REGISTRY_HOST=rohana.axion1337.de
# Reichweite (Entscheidung sorb 2026-08-21, Option C): Bestand plus die
# letzten drei Fassungen je Repo - so weit, wie ein Rollback zielt.
- REGISTRY_TAGS_JE_REPO=3
# Nicht je Scrape herleiten: bei 15 s waeren das ~21 000
# Registry-Anfragen am Tag; eine Runde kostet ~90.
- SOLL_INTERVALL_SEKUNDEN=3600
# Entschiedene CRITICAL-Befunde (#0051). Liegt im selben ./cve-Ordner,
# der hier als /app eingehaengt ist - ausdruecklich gesetzt, damit man
# die Datei findet, ohne den Vorgabewert im Quelltext zu suchen.
- ENTSCHEIDUNGEN_FILE=/app/entscheidungen.json
volumes:
- ./cve:/app:ro
- cve_results:/results:ro
- cve_exporter_state:/state
- cve_targets:/targets
command: python3 /app/cve-exporter.py
networks: networks:
- traefik - traefik
loki: loki:
image: grafana/loki:3.7.1 # 3.7.1 -> 3.7.6, gemessen: 41 HIGH -> 8, keine CRITICAL in beiden.
image: grafana/loki:3.7.6
container_name: loki container_name: loki
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml:ro - ./loki:/etc/loki:ro
- loki_data:/loki - loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml command: -config.file=/etc/loki/loki-config.yaml
# Port 3100 oeffentlich: externe Alloys pushen Logs hierher. # ⚠️ Externe Alloys pushen Logs hierher; Loki hat ebenfalls keine
# Ebenfalls per Firewall auf bekannte Absender-IPs einschraenken. # Authentisierung. Bis 2026-08-21 auf 0.0.0.0 gebunden, seither wie
# Prometheus: privater vSwitch fuer die Absender, localhost fuer den Host.
ports: ports:
- "3100:3100" - "10.0.0.3:3100:3100"
- "127.0.0.1:3100:3100"
networks: networks:
- traefik - traefik
alloy: alloy:
image: grafana/alloy:v1.16.0 # v1.16.0 -> v1.18.1, gemessen: 44 HIGH -> 14. v1.19.0 gibt es nur als
# Vorabfassung; im Cluster laeuft alloy aus dem Chart, nicht von hier.
image: grafana/alloy:v1.18.1
container_name: alloy container_name: alloy
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./alloy/config.alloy:/etc/alloy/config.alloy:ro - ./alloy:/etc/alloy:ro
- /var/log:/var/log:ro - /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
# Muss persistent sein, sonst ist die Positions-Datei nach jedem
# Recreate weg und Alloy liest alle Container-Logs von vorn. Loki
# weist die alten Eintraege dann ab (reject_old_samples, Default 7d).
- alloy_data:/var/lib/alloy
command: run --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy command: run --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy
cap_add: cap_add:
- DAC_READ_SEARCH - DAC_READ_SEARCH
@@ -55,7 +199,20 @@ services:
- loki - loki
grafana: grafana:
image: grafana/grafana:12.0.0 # ⚠️ NICHT auf die 12er-Reihe zurueckdrehen, weil irgendwo eine groessere
# HIGH-Zahl steht. Ab 13.x liegen 13 Datenquellen-Plugins IM Image (12.4.9
# hatte keine): aus 5 Scan-Zielen werden 16, und die Befunde sitzen
# saemtlich in diesen Plugin-Binaries - opentsdb, jaeger, stackdriver,
# tempo, cloudmonitoring, also Datenquellen, die wir nicht benutzen.
# Der KERN von 13.2.0 traegt 0 CRITICAL und 0 HIGH; 12.4.9 trug dort 1
# und 3.
#
# ⚠️ UND: dieselbe Lage hat drei verschiedene Zahlen, je nachdem, was man
# zaehlt - 162 Vorkommen, 63 Prometheus-Serien, 28 verschiedene CVEs.
# Dieselbe Go-stdlib wiederholt sich ueber 13 Binaries. Auf dem Dashboard
# (das Serien zaehlt) steht der Sprung als 70 -> 63, also WENIGER.
# Wer hier vergleicht, muss dieselbe Zaehlweise auf beiden Seiten nehmen.
image: grafana/grafana:13.2.0
container_name: grafana container_name: grafana
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -72,7 +229,7 @@ services:
- "traefik.docker.network=traefik" - "traefik.docker.network=traefik"
- "traefik.http.routers.grafana.rule=Host(`selendis.axion1337.de`)" - "traefik.http.routers.grafana.rule=Host(`selendis.axion1337.de`)"
- "traefik.http.routers.grafana.entrypoints=websecure" - "traefik.http.routers.grafana.entrypoints=websecure"
- "traefik.http.routers.grafana.tls.certresolver=le" - "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
- "traefik.http.services.grafana.loadbalancer.server.port=3000" - "traefik.http.services.grafana.loadbalancer.server.port=3000"
depends_on: depends_on:
- prometheus - prometheus
@@ -81,7 +238,7 @@ services:
- traefik - traefik
node-exporter: node-exporter:
image: prom/node-exporter:v1.9.1 image: prom/node-exporter:v1.12.1
container_name: node-exporter container_name: node-exporter
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -94,15 +251,25 @@ services:
- '--path.rootfs=/rootfs' - '--path.rootfs=/rootfs'
- '--web.listen-address=:9100' - '--web.listen-address=:9100'
- '--web.disable-exporter-metrics' - '--web.disable-exporter-metrics'
# Nur localhost: gescrapt wird aus demselben Host heraus, von aussen hat
# hier niemand etwas zu suchen. Bis 2026-08-21 stand auch das auf 0.0.0.0.
ports: ports:
- "9100:9100" - "127.0.0.1:9100:9100"
networks: networks:
- traefik - traefik
volumes: volumes:
prometheus_data: prometheus_data:
alertmanager_data:
matrix_alerts_data:
release_watch_data:
cve_results:
cve_trivy_cache:
cve_exporter_state:
cve_targets:
grafana_data: grafana_data:
loki_data: loki_data:
alloy_data:
networks: networks:
traefik: traefik:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
{
"uid": "threadnet-clamav",
"title": "ClamAV — blockierte Inhalte",
"description": "Wie oft ClamAV tatsaechlich etwas blockiert (management #0077). Zwei Quellen: der Client-seitige Scanner (clamav-http-scanner, greift beim Senden UND beim Empfangen, deckt auch E2E-verschluesselte Raeume ab) und das Synapse-Modul (nur unverschluesselte Uploads). Das dritte Panel zeigt Fail-open-Faelle - wenn clamd nicht erreichbar ist, laesst der Scanner Dateien bewusst durch; ohne dieses Panel bliebe das unsichtbar.",
"tags": ["security", "clamav", "matrix"],
"timezone": "browser",
"schemaVersion": 41,
"editable": true,
"time": { "from": "now-7d", "to": "now" },
"refresh": "5m",
"panels": [
{
"type": "stat",
"title": "Blockiert — Client (Senden + Empfangen)",
"description": "clamav-http-scanner. Deckt auch verschluesselte Raeume ab, weil der Client vor dem Verschluesseln bzw. nach dem Entschluesseln prueft.",
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 },
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"queryType": "instant",
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV flagged\" [$__range]))"
}
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 } ] }
},
"overrides": []
}
},
{
"type": "stat",
"title": "Blockiert — Synapse-Modul",
"description": "Server-seitiges Modul, sieht nur unverschluesselte Uploads. Null ist hier der Normalfall, solange der Client schon vor dem Upload blockt.",
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 },
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"queryType": "instant",
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/matrix-stack-synapse.*\"} |= \"ClamAV rejected an upload\" [$__range]))"
}
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 } ] }
},
"overrides": []
}
},
{
"type": "stat",
"title": "Fail-open — Scanner nicht erreichbar",
"description": "Wenn clamd nicht antwortet, laesst der Scanner die Datei bewusst durch (fail-open). Jeder Treffer hier ist eine Datei, die UNGEPRUEFT durchging - deshalb rot ab eins.",
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 },
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"queryType": "instant",
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV scan failed\" [$__range]))"
}
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] }
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Blockierte Inhalte ueber Zeit",
"gridPos": { "h": 8, "w": 14, "x": 0, "y": 4 },
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"legendFormat": "Client (Senden/Empfangen)",
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV flagged\" [$__interval]))"
},
{
"refId": "B",
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"legendFormat": "Synapse-Modul",
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/matrix-stack-synapse.*\"} |= \"ClamAV rejected an upload\" [$__interval]))"
},
{
"refId": "C",
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"legendFormat": "Fail-open",
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV scan failed\" [$__interval]))"
}
],
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "drawStyle": "bars", "fillOpacity": 60, "stacking": { "mode": "normal" } } },
"overrides": []
},
"options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }
},
{
"type": "table",
"title": "Nach Signatur",
"description": "Welche Schadsoftware tatsaechlich auftaucht. Die Signatur wird aus der Logzeile geschnitten.",
"gridPos": { "h": 8, "w": 10, "x": 14, "y": 4 },
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"queryType": "instant",
"format": "table",
"expr": "sum by (signature) (count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV flagged\" | regexp \"ClamAV flagged an upload/download: (?P<signature>[^\\\\s]+)\" [$__range]))"
}
],
"transformations": [ { "id": "organize", "options": { "excludeByName": { "Time": true } } } ],
"fieldConfig": { "defaults": {}, "overrides": [] }
},
{
"type": "logs",
"title": "Ereignisse",
"description": "Rohzeilen beider Quellen, neueste zuerst.",
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 12 },
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
"expr": "{job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/(clamav-http-scanner|matrix-stack-synapse).*\"} |~ \"ClamAV (flagged|rejected an upload|scan failed)\""
}
],
"options": { "showTime": true, "sortOrder": "Descending", "wrapLogMessage": true }
}
]
}
@@ -0,0 +1,421 @@
{
"title": "CVE-Übersicht (Trivy)",
"uid": "cve-overview",
"tags": [
"security",
"cve",
"gitops-47"
],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "5m",
"time": {
"from": "now-30d",
"to": "now"
},
"panels": [
{
"type": "stat",
"title": "Offene CRITICAL",
"id": 1,
"gridPos": {
"x": 0,
"y": 0,
"w": 4,
"h": 4
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"targets": [
{
"expr": "sum(trivy_vuln_count{severity=\"CRITICAL\"}) or vector(0)",
"instant": true,
"refId": "A"
}
]
},
{
"type": "stat",
"title": "Offene HIGH",
"id": 2,
"gridPos": {
"x": 4,
"y": 0,
"w": 4,
"h": 4
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 1
}
]
}
},
"overrides": []
},
"targets": [
{
"expr": "sum(trivy_vuln_count{severity=\"HIGH\"}) or vector(0)",
"instant": true,
"refId": "A"
}
]
},
{
"type": "stat",
"title": "MEDIUM/LOW (Summe)",
"id": 3,
"gridPos": {
"x": 8,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"expr": "sum(trivy_vuln_count{severity=~\"MEDIUM|LOW\"}) or vector(0)",
"instant": true,
"refId": "A"
}
]
},
{
"type": "stat",
"title": "Ältester Scan (Stunden)",
"id": 4,
"gridPos": {
"x": 12,
"y": 0,
"w": 4,
"h": 4
},
"fieldConfig": {
"defaults": {
"unit": "h",
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 48
}
]
}
},
"overrides": []
},
"targets": [
{
"expr": "(time() - min(trivy_last_scan_timestamp)) / 3600",
"instant": true,
"refId": "A"
}
]
},
{
"type": "table",
"title": "Offene CVEs (HIGH/CRITICAL) — CVE · Ort · Typ · Paket · Fix · seit",
"id": 5,
"gridPos": {
"x": 0,
"y": 4,
"w": 24,
"h": 12
},
"targets": [
{
"expr": "trivy_vuln_info",
"instant": true,
"format": "table",
"refId": "A"
},
{
"expr": "trivy_vuln_first_seen_timestamp * 1000",
"instant": true,
"format": "table",
"refId": "B"
}
],
"transformations": [
{
"id": "merge",
"options": {}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"__name__": true,
"job": true,
"instance": true,
"Value #A": true
},
"renameByName": {
"cve": "CVE",
"severity": "Severity",
"host": "Ort (Host)",
"target_type": "Typ",
"target": "Target",
"pkg": "Paket",
"installed": "Installiert",
"fixed_version": "Fix-Version",
"Value #B": "Erstmals gesehen"
}
}
}
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Erstmals gesehen"
},
"properties": [
{
"id": "unit",
"value": "dateTimeAsIso"
}
]
},
{
"matcher": {
"id": "byName",
"options": "CVE"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "NVD (Mitigation/Details)",
"targetBlank": true,
"url": "https://nvd.nist.gov/vuln/detail/${__value.text}"
}
]
}
]
}
]
}
},
{
"type": "state-timeline",
"title": "Zeitstrahl: offene HIGH/CRITICAL je Target",
"id": 6,
"gridPos": {
"x": 0,
"y": 16,
"w": 24,
"h": 8
},
"targets": [
{
"expr": "count by (target) (trivy_vuln_info)",
"legendFormat": "{{target}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 70
}
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Funde je Severity über Zeit",
"id": 7,
"gridPos": {
"x": 0,
"y": 24,
"w": 24,
"h": 8
},
"targets": [
{
"expr": "sum by (severity) (trivy_vuln_count)",
"legendFormat": "{{severity}}",
"refId": "A"
}
]
},
{
"type": "stat",
"title": "Deckung der Zielmenge",
"id": 8,
"gridPos": {
"x": 16,
"y": 0,
"w": 4,
"h": 4
},
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "orange",
"value": 0.9
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": []
},
"targets": [
{
"expr": "cve_target_coverage_ratio",
"instant": true,
"refId": "A"
}
]
},
{
"type": "stat",
"title": "Ungeprüfte Images",
"id": 9,
"gridPos": {
"x": 20,
"y": 0,
"w": 4,
"h": 4
},
"fieldConfig": {
"defaults": {
"unit": "none",
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"targets": [
{
"expr": "cve_targets_missing or vector(0)",
"instant": true,
"refId": "A"
}
]
},
{
"type": "bargauge",
"title": "Frische der Zielquellen (Sekunden seit letzter Herleitung)",
"id": 10,
"gridPos": {
"x": 0,
"y": 32,
"w": 24,
"h": 6
},
"description": "Faellt eine Quelle aus, wird die Soll-Menge kleiner - und die Deckung sieht dadurch BESSER aus statt schlechter. Diese Zeile ist die einzige Stelle, an der ein Ausfall der Herleitung von Erfolg zu unterscheiden ist (ADR-0026).",
"fieldConfig": {
"defaults": {
"unit": "s",
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 7200
},
{
"color": "red",
"value": 10800
}
]
}
},
"overrides": []
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal"
},
"targets": [
{
"expr": "cve_target_source_stale",
"instant": true,
"refId": "A",
"legendFormat": "{{quelle}}"
}
]
}
]
}
@@ -37,3 +37,10 @@ providers:
allowUiUpdates: true allowUiUpdates: true
options: options:
path: /var/lib/grafana/dashboards/general path: /var/lib/grafana/dashboards/general
- name: security
folder: 'Security'
type: file
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards/security
+290
View File
@@ -0,0 +1,290 @@
# Alert-Regeln (gitops#32). Bewusst wenige, dafuer die real erlebten Fehlerklassen:
# - TargetDown: "Exporter/Dienst tot und keiner merkt es" (coturn-Klasse)
# - KubePodRestartLoop: Crashloops im k3s-Cluster (36k-coturn-Restarts-Klasse)
# - HostOom/HostLowMemory/HostLowDisk/HostHighSwap: Ressourcendruck der Hosts
groups:
- name: axion-basics
rules:
- alert: TargetDown
expr: up == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Target {{ $labels.job }}/{{ $labels.instance }} ist seit 5m nicht erreichbar"
- alert: KubePodRestartLoop
expr: increase(kube_pod_container_status_restarts_total[1h]) > 3
for: 10m
labels:
severity: warning
annotations:
summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} restartet wiederholt ({{ $value | printf \"%.0f\" }}x in 1h)"
- alert: HostOomKill
expr: increase(node_vmstat_oom_kill[15m]) > 0
labels:
severity: warning
annotations:
summary: "OOM-Kill auf {{ $labels.instance }}"
- alert: HostLowMemory
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.08
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }}: <8% RAM verfuegbar seit 15m"
- alert: HostHighSwap
expr: (node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes) / node_memory_SwapTotal_bytes > 0.5
for: 30m
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }}: >50% Swap in Nutzung seit 30m"
- alert: HostLowDisk
expr: node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} < 0.10
for: 15m
labels:
severity: critical
annotations:
summary: "{{ $labels.instance }}: <10% Platz auf / frei"
# CVE-Pipeline (gitops#47): Funde aus den Trivy-Scans. Pflichtfelder je Alarm:
# CVE-ID, Mitigation (Fix-Version + NVD-Link), Ort (host), Typ (target_type);
# der Zeitstrahl kommt aus trivy_vuln_first_seen_timestamp + resolved-Edit.
# Alle Regeln routen per room-Label in den Security-Raum.
- name: axion-cve
rules:
# Aggregiert pro Image statt pro CVE (gitops#51, Entscheidung sorb
# 2026-08-01): ~1000 Einzelalarme waeren Rauschen; die menschlich
# handhabbare Einheit ist "Image X hat N kritische CVEs". Die
# CVE-Details (Pflichtfelder) liegen im Grafana-Dashboard cve-overview,
# die trivy_vuln_info-Einzelserien bleiben dafuer erhalten.
- alert: TrivyCriticalVulns
expr: count by (target, target_type, host) (trivy_vuln_info{severity="CRITICAL"}) > 0
labels:
severity: critical
room: security
annotations:
summary: "{{ $labels.target }} [{{ $labels.target_type }}@{{ $labels.host }}]: {{ $value }} CRITICAL-CVEs — Details: https://selendis.axion1337.de/d/cve-overview"
- alert: TrivyHighVulns
expr: count by (target, target_type, host) (trivy_vuln_info{severity="HIGH"}) > 0
for: 24h
labels:
severity: warning
room: security
annotations:
summary: "{{ $labels.target }} [{{ $labels.target_type }}@{{ $labels.host }}]: {{ $value }} HIGH-CVEs (seit 24h offen) — Details: https://selendis.axion1337.de/d/cve-overview"
- alert: TrivyScanStale
expr: time() - trivy_last_scan_timestamp > 172800
labels:
severity: warning
room: security
annotations:
summary: "CVE-Scan fuer {{ $labels.target }} ist aelter als 2 Tage — Scanner pruefen (ein still gestorbener Scanner macht blind)"
# TrivyScanStale hat eine Blindstelle, die es prinzipiell nicht schliessen
# kann: ein Target OHNE je erfolgreichen Bericht hat gar keine Serie, an der
# 'time() - ...' haengen koennte. Es bleibt also still. Die beiden Regeln
# darunter fangen die zwei Faelle ab, die dahinterstecken.
- alert: TrivyReportUnreadable
expr: trivy_report_read_errors > 0
for: 30m
labels:
severity: warning
room: security
annotations:
summary: "{{ $value }} CVE-Bericht(e) sind seit 30 min unlesbar — die betroffenen Targets werden NICHT ausgewertet und faellt sonst niemandem auf"
- alert: TrivyNoReports
expr: trivy_reports_total == 0
for: 1h
labels:
severity: critical
room: security
annotations:
summary: "Der CVE-Exporter findet ueberhaupt keine Berichte mehr — der Scanner liefert nichts, die Schwachstellen-Sicht ist blind"
# --- Deckung der Zielmenge (#0106, ADR-0026) ---------------------
# Bis 2026-08-21 konnte die Pipeline nur sagen, WAS sie gescannt hat.
# Was sie nie angesehen hatte, war unsichtbar: 27 von 51 laufenden
# Images (52 %) - darunter der Web-Client, beide Traefik-Schichten und
# die Registry selbst. Die Zielliste war handgepflegt.
# 6h, weil eine volle Runde ueber ~65 Images dauert: nach einem
# Ausrollen ist die Luecke voruebergehend echt und kein Fehler.
- alert: CveTargetsMissing
expr: cve_targets_missing > 0
for: 6h
labels:
severity: warning
room: security
annotations:
summary: "{{ $value }} laufende Images werden NICHT auf CVEs geprueft — die Schwachstellen-Sicht hat ein Loch, das sie selbst nicht zeigt. Details: https://selendis.axion1337.de/d/cve-overview"
- alert: CveTargetsOrphaned
expr: cve_targets_orphaned > 0
for: 6h
labels:
severity: info
room: security
annotations:
summary: "{{ $value }} CVE-Bericht(e) betreffen Images, die nirgends laufen — Befunde, auf die niemand handeln kann (Rauschen, das wie Signal aussieht)"
# ⚠️ DIE WICHTIGSTE DER VIER. Faellt eine Quelle aus, wird die
# Soll-Menge KLEINER - und die Deckung saehe damit BESSER aus, nicht
# schlechter. Ein Ausfall waere ohne diese Regel von Erfolg nicht zu
# unterscheiden. Schwelle: 3x das Herleitungs-Intervall (1h).
- alert: CveTargetSourceStale
expr: cve_target_source_stale > 10800
labels:
severity: warning
room: security
annotations:
summary: "Die Quelle '{{ $labels.quelle }}' der CVE-Zielmenge ist seit {{ $value }}s nicht erneuert worden — die Zielmenge schrumpft still, und die Deckung sieht dadurch BESSER aus statt schlechter"
# Der Fall, den die drei darueber NICHT sehen: alle Quellen antworten
# sauber, aber leer. Dann ist 'missing' 0 (leere Soll-Menge) und
# 'stale' frisch - und es wird nichts mehr gescannt. In
# test_targets.py als "leere Menge ist NICHT dasselbe wie Erfolg".
- alert: CveTargetsNoneDesired
expr: cve_targets_desired_total == 0
for: 30m
labels:
severity: critical
room: security
annotations:
summary: "Die CVE-Zielmenge ist LEER — es wird nichts mehr geprueft, obwohl keine Quelle einen Fehler meldet"
# Sicherungen (management #0030). Bis 2026-08-14 gab es hierzu KEINE Regel: ein
# fehlgeschlagenes naechtliches Backup waere unbemerkt geblieben - genau der stille
# Ausfall, den das Issue beschreibt. Metriken kommen aus kube-state-metrics im
# Matrix-Cluster (Alloy -> remote_write, kube_job_*/kube_cronjob_* passieren den
# Filter, der nur go_.*|process_.* verwirft).
# --- Entschieden oder offen (#0051) -------------------------------
# ⚠️ Diese vier bewachen eine ENTSCHEIDUNG, kein Werkzeug. Ein CRITICAL,
# zu dem es nichts zu tun gibt, ist erlaubt - aber nur benannt, begruendet
# und mit Pruefdatum. Ohne diese Regeln waere 'entschieden' ein Wort in
# einer Datei, das mit der Zeit still zu 'vergessen' wird.
- alert: CveCriticalOffen
expr: cve_critical_offen > 0
for: 24h
labels:
severity: warning
room: security
annotations:
summary: "{{ $value }} CRITICAL auf laufenden Images ohne Entscheidung — weder behoben noch mit Begruendung und Pruefdatum in cve/entscheidungen.json. Details: https://selendis.axion1337.de/d/cve-overview"
# Der Ablauf ist der Sinn der Sache: eine Entscheidung ohne Verfallsdatum
# ist keine Entscheidung, sondern Wegschauen mit Zusatzschritt.
- alert: CveEntscheidungAbgelaufen
expr: cve_entscheidungen_abgelaufen > 0
for: 6h
labels:
severity: warning
room: security
annotations:
summary: "{{ $value }} CVE-Entscheidung(en) haben ihr Pruefdatum ueberschritten — die Begruendung von damals gilt bis zum Gegenbeweis nicht mehr"
- alert: CveEntscheidungOhneBefund
expr: cve_entscheidungen_ohne_befund > 0
for: 24h
labels:
severity: info
room: security
annotations:
summary: "{{ $value }} CVE-Entscheidung(en) treffen auf keinen Befund mehr — Altpapier in cve/entscheidungen.json, das kuenftig Befunde decken koennte, die niemand geprueft hat"
# ⚠️ Eine unlesbare Datei laesst ALLES als offen zaehlen (so gebaut). Das
# faellt ueber CveCriticalOffen auf - aber erst nach 24h und ohne den Grund
# zu nennen. Diese Regel nennt ihn sofort.
- alert: CveEntscheidungenUnlesbar
expr: cve_entscheidungen_lesefehler > 0
for: 30m
labels:
severity: warning
room: security
annotations:
summary: "cve/entscheidungen.json ist nicht lesbar — bis das behoben ist, zaehlt jeder CRITICAL als unentschieden"
# ⚠️ Die Regel, die am 2026-08-23 gefehlt hat. Ein k3s-Neustart legte
# kube-state-metrics und Alloy lahm, `kube_pod_container_info` war leer,
# die Herleitung nahm das als Erfolg - und die Zielmenge fiel von 54 auf
# 12. Der Scanner loeschte 42 Berichte, das Dashboard meldete Deckung 1,0.
# Keine der vier Regeln darueber schlug an: die Menge war nicht LEER, und
# die Zeitstempel waren FRISCH. Die Luecke sass genau dazwischen.
#
# ⚠️ Sie feuert auch bei einem gewollten Rueckbau. Das ist Absicht: ein
# Einbruch der Pruefmenge um 40 % ist immer eine Zeile wert, egal warum.
- alert: CveZielmengeEingebrochen
expr: cve_targets_desired_total < 0.6 * max_over_time(cve_targets_desired_total[24h])
for: 15m
labels:
severity: warning
room: security
annotations:
summary: "Die CVE-Zielmenge ist auf {{ $value }} Ziele eingebrochen — ueber 40 % weniger als in den letzten 24 h. Faellt eine Quelle still aus, sieht das Dashboard danach BESSER aus, nicht schlechter."
- name: axion-backup
rules:
# Ein Backup- oder Probe-Job ist fehlgeschlagen.
- alert: BackupJobFailed
expr: max by (namespace, job_name) (kube_job_status_failed{job_name=~"(synapse|authentik|wikijs)-backup.*|restore-drill.*"}) > 0
for: 15m
labels:
severity: critical
annotations:
summary: "Backup-Job {{ $labels.namespace }}/{{ $labels.job_name }} ist fehlgeschlagen — ohne Sicherung ist der naechste Ausfall ein Datenverlust"
# Der CronJob plant gar nicht mehr (ausgesetzt, geloescht, Cluster-Problem).
# 26h Toleranz fuer die taeglichen Laeufe um 03:00/03:15/03:30.
# Fallback auf die Anlagezeit: ein frisch (neu) angelegter CronJob hat noch
# keine last_schedule_time-Serie - ein Ausdruck ueber eine fehlende Serie
# liefert nichts, der Alarm koennte also nie feuern. max by(...) fasst beide
# Metriken zu EINER Serie je CronJob zusammen; ohne das wuerde die nie
# aktualisierte created-Serie nach Ablauf der Frist dauerhaft falsch feuern
# ('or' matcht inkl. __name__, ergibt also eine Vereinigung beider Serien).
- alert: BackupNotRunning
expr: time() - max by (namespace, cronjob) (kube_cronjob_status_last_schedule_time{cronjob=~"(synapse|authentik|wikijs)-backup"} or kube_cronjob_created{cronjob=~"(synapse|authentik|wikijs)-backup"}) > 93600
labels:
severity: critical
annotations:
summary: "Seit ueber 26h kein Lauf von {{ $labels.cronjob }} — CronJob ausgesetzt oder verschwunden?"
# Die monatlichen Restore-Proben laufen nicht mehr - restore-drill (Datenbanken)
# und restore-drill-media (Synapse-Medien). Praefix-Matcher und cronjob-Name in der
# Meldung, sonst sagt der Alarm nicht, WELCHE Probe steht. Eine Sicherung, die nie
# zurueckgespielt wurde, ist eine Vermutung — deshalb ist auch das Ausbleiben
# der PRUEFUNG ein Alarm, nicht nur ihr Fehlschlag. 40 Tage = Monatsrhythmus + Puffer.
# Bis zum ersten REGULAEREN Lauf gibt es keine last_schedule_time-Serie (ein
# manuell ausgeloester Job setzt sie nicht) - ohne Fallback waere dieser
# Alarm bis dahin blind. Gleiche max-by-Konstruktion wie oben.
- alert: RestoreDrillStale
expr: time() - max by (namespace, cronjob) (kube_cronjob_status_last_schedule_time{cronjob=~"restore-drill.*"} or kube_cronjob_created{cronjob=~"restore-drill.*"}) > 3456000
labels:
severity: warning
annotations:
summary: "{{ $labels.cronjob }} seit ueber 40 Tagen nicht gelaufen — Wiederherstellbarkeit ist wieder unbewiesen (Verfahren: notfallhandbuch)"
# Ein Backup-CronJob existiert gar nicht mehr (geloescht, Kustomization
# entfernt, Namespace weg). Bei den Alarmen oben verschwindet dann die
# Serie - und eine verschwundene Serie ist in Prometheus kein Alarm,
# sondern Stille. Genau das war der Fehler, den dieses Regelwerk
# verhindern soll, also ist das Fehlen selbst der Alarm.
# for: 30m puffert kurze Luecken der Metrik-Pipeline ab.
- alert: BackupCronJobMissing
expr: >-
absent(kube_cronjob_info{namespace="matrix", cronjob="synapse-backup"})
or absent(kube_cronjob_info{namespace="matrix", cronjob="wikijs-backup"})
or absent(kube_cronjob_info{namespace="authentik", cronjob="authentik-backup"})
or absent(kube_cronjob_info{namespace="matrix", cronjob="restore-drill"})
or absent(kube_cronjob_info{namespace="matrix", cronjob="restore-drill-media"})
for: 30m
labels:
severity: critical
annotations:
summary: "Ein Backup-/Probe-CronJob fehlt im Cluster — Sicherung oder Wiederherstellungs-Nachweis laeuft nicht mehr"
# Die Alarmkette meldet ihr eigenes Reissen. Ironie inklusive: schlaegt die
# Zustellung komplett fehl, kommt auch dieser Alarm nicht an - er ist dann
# aber in Prometheus/Grafana sichtbar, statt dass gar nichts existiert.
# Teilausfaelle (ein Receiver von mehreren, Rate-Limit 429) meldet er sauber.
- alert: AlertDeliveryFailing
expr: increase(alertmanager_notifications_failed_total[15m]) > 0
labels:
severity: critical
annotations:
summary: "Alertmanager konnte Benachrichtigungen nicht zustellen ({{ $labels.integration }}) — Alarme laufen ins Leere"
+289
View File
@@ -0,0 +1,289 @@
# Regeltests fuer die Deckungs-Alarme (#0106) und die Entscheidungs-Alarme (#0051).
# docker run --rm --entrypoint promtool -v "$PWD/prometheus:/p:ro" \
# prom/prometheus:v3.14.0 test rules /p/alerts_test.yml
#
# ⚠️ ZWECK: Eine Regel, die nur je gruen war, ist eine Vermutung. In diesem
# Projekt ist "meldet Erfolg, ist aber blind" die haeufigste Fehlerklasse
# (AAR 2026-08-01, Befund 1 und 3). Deshalb steht zu jeder Regel ein Fall, in
# dem sie feuern MUSS, und einer, in dem sie schweigen MUSS - sonst laesst sich
# eine Regel, die immer feuert, von einer richtigen nicht unterscheiden.
#
# Erste Regeltests in diesem Stack ueberhaupt; die uebrigen 16 Regeln sind
# bewusst nicht mit abgedeckt (Nicht-Ziel von #0106).
rule_files:
- alerts.yml
evaluation_interval: 1m
tests:
# --- CveTargetsMissing ----------------------------------------------------
- interval: 1m
name: "Luecke in der Deckung feuert nach 6h"
input_series:
- series: 'cve_targets_missing'
values: '24+0x480' # 8h lang 24 fehlende Images
alert_rule_test:
- eval_time: 5h
alertname: CveTargetsMissing
exp_alerts: [] # noch in der Karenz - eine volle Runde darf dauern
- eval_time: 7h
alertname: CveTargetsMissing
exp_alerts:
- exp_labels:
severity: warning
room: security
exp_annotations:
summary: "24 laufende Images werden NICHT auf CVEs geprueft — die Schwachstellen-Sicht hat ein Loch, das sie selbst nicht zeigt. Details: https://selendis.axion1337.de/d/cve-overview"
- interval: 1m
name: "Volle Deckung schweigt"
input_series:
- series: 'cve_targets_missing'
values: '0+0x480'
alert_rule_test:
- eval_time: 7h
alertname: CveTargetsMissing
exp_alerts: []
# --- CveTargetsOrphaned ---------------------------------------------------
- interval: 1m
name: "Berichte ohne laufendes Image feuern"
input_series:
- series: 'cve_targets_orphaned'
values: '2+0x480'
alert_rule_test:
- eval_time: 7h
alertname: CveTargetsOrphaned
exp_alerts:
- exp_labels:
severity: info
room: security
exp_annotations:
summary: "2 CVE-Bericht(e) betreffen Images, die nirgends laufen — Befunde, auf die niemand handeln kann (Rauschen, das wie Signal aussieht)"
# --- CveTargetSourceStale (die wichtigste) --------------------------------
- interval: 1m
name: "Eine eingefrorene Quelle faellt auf"
input_series:
# Die Registry-Quelle altert, die anderen beiden bleiben frisch.
- series: 'cve_target_source_stale{quelle="registry"}'
values: '0+60x400' # waechst um 60s je Minute -> nach 180 min > 10800
- series: 'cve_target_source_stale{quelle="cluster"}'
values: '30+0x400'
- series: 'cve_target_source_stale{quelle="operating"}'
values: '30+0x400'
alert_rule_test:
- eval_time: 2h
alertname: CveTargetSourceStale
exp_alerts: [] # 7200s < 10800s - noch in Ordnung
- eval_time: 5h
alertname: CveTargetSourceStale
exp_alerts:
- exp_labels:
severity: warning
room: security
quelle: registry # ⚠️ NUR die eingefrorene, nicht alle drei
exp_annotations:
summary: "Die Quelle 'registry' der CVE-Zielmenge ist seit 18000s nicht erneuert worden — die Zielmenge schrumpft still, und die Deckung sieht dadurch BESSER aus statt schlechter"
# --- CveTargetsNoneDesired ------------------------------------------------
# Der Fall, den die drei anderen NICHT sehen: alle Quellen antworten sauber,
# aber leer. missing = 0, stale frisch - und es wird nichts mehr geprueft.
- interval: 1m
name: "Leere Zielmenge trotz gesunder Quellen"
input_series:
- series: 'cve_targets_desired_total'
values: '0+0x120'
- series: 'cve_targets_missing'
values: '0+0x120'
- series: 'cve_target_source_stale{quelle="cluster"}'
values: '5+0x120'
alert_rule_test:
- eval_time: 20m
alertname: CveTargetsNoneDesired
exp_alerts: [] # Karenz 30m
- eval_time: 40m
alertname: CveTargetsNoneDesired
exp_alerts:
- exp_labels:
severity: critical
room: security
exp_annotations:
summary: "Die CVE-Zielmenge ist LEER — es wird nichts mehr geprueft, obwohl keine Quelle einen Fehler meldet"
# Gegenprobe im selben Lauf: die anderen drei schweigen hier, obwohl die
# Lage schlecht ist. Genau deshalb braucht es die vierte Regel.
- eval_time: 40m
alertname: CveTargetsMissing
exp_alerts: []
- eval_time: 40m
alertname: CveTargetSourceStale
exp_alerts: []
# --- Gegenprobe: gesunder Zustand loest KEINEN der vier Alarme aus --------
- interval: 1m
name: "Alles in Ordnung bleibt still"
input_series:
- series: 'cve_targets_missing'
values: '0+0x480'
- series: 'cve_targets_orphaned'
values: '0+0x480'
- series: 'cve_targets_desired_total'
values: '65+0x480'
- series: 'cve_target_source_stale{quelle="cluster"}'
values: '120+0x480'
- series: 'cve_target_source_stale{quelle="operating"}'
values: '120+0x480'
- series: 'cve_target_source_stale{quelle="registry"}'
values: '900+0x480'
alert_rule_test:
- eval_time: 7h
alertname: CveTargetsMissing
exp_alerts: []
- eval_time: 7h
alertname: CveTargetsOrphaned
exp_alerts: []
- eval_time: 7h
alertname: CveTargetsNoneDesired
exp_alerts: []
- eval_time: 7h
alertname: CveTargetSourceStale
exp_alerts: []
# --- CveCriticalOffen (#0051) ---------------------------------------------
- interval: 1m
name: "Ein CRITICAL ohne Entscheidung faellt nach 24h auf"
input_series:
- series: 'cve_critical_offen'
values: '3+0x1800'
alert_rule_test:
- eval_time: 20h
alertname: CveCriticalOffen
exp_alerts: [] # Karenz: ein frisch entdeckter Befund darf
# einen Arbeitstag lang unentschieden sein
- eval_time: 26h
alertname: CveCriticalOffen
exp_alerts:
- exp_labels:
severity: warning
room: security
exp_annotations:
summary: "3 CRITICAL auf laufenden Images ohne Entscheidung — weder behoben noch mit Begruendung und Pruefdatum in cve/entscheidungen.json. Details: https://selendis.axion1337.de/d/cve-overview"
# --- CveEntscheidungAbgelaufen --------------------------------------------
# ⚠️ Der eigentliche Zweck der Entscheidungsdatei. Ohne diese Regel wuerde
# aus "wir haben uns das angesehen und es ist vertretbar" nach ein paar
# Monaten still "das steht halt so in der Datei".
- interval: 1m
name: "Ein ueberschrittenes Pruefdatum meldet sich"
input_series:
- series: 'cve_entscheidungen_abgelaufen'
values: '0+0x60 1+0x600' # erst alles gueltig, dann laeuft eine ab
alert_rule_test:
- eval_time: 1h
alertname: CveEntscheidungAbgelaufen
exp_alerts: [] # noch nichts abgelaufen
- eval_time: 8h
alertname: CveEntscheidungAbgelaufen
exp_alerts:
- exp_labels:
severity: warning
room: security
exp_annotations:
summary: "1 CVE-Entscheidung(en) haben ihr Pruefdatum ueberschritten — die Begruendung von damals gilt bis zum Gegenbeweis nicht mehr"
# --- CveEntscheidungenUnlesbar --------------------------------------------
- interval: 1m
name: "Eine kaputte Entscheidungsdatei nennt sich selbst"
input_series:
- series: 'cve_entscheidungen_lesefehler'
values: '1+0x120'
# Gleichzeitig zaehlt alles als offen - so ist der Exporter gebaut.
- series: 'cve_critical_offen'
values: '63+0x120'
alert_rule_test:
- eval_time: 15m
alertname: CveEntscheidungenUnlesbar
exp_alerts: [] # Karenz 30m
- eval_time: 45m
alertname: CveEntscheidungenUnlesbar
exp_alerts:
- exp_labels:
severity: warning
room: security
exp_annotations:
summary: "cve/entscheidungen.json ist nicht lesbar — bis das behoben ist, zaehlt jeder CRITICAL als unentschieden"
# ⚠️ Gegenprobe zum Zusammenspiel: CveCriticalOffen schweigt hier noch
# (Karenz 24h). Ohne die Regel darueber bliebe der wahre Grund also
# einen ganzen Tag lang unsichtbar.
- eval_time: 45m
alertname: CveCriticalOffen
exp_alerts: []
# --- Gegenprobe: entschiedener Bestand bleibt still ------------------------
- interval: 1m
name: "Alles entschieden und gueltig bleibt still"
input_series:
- series: 'cve_critical_offen'
values: '0+0x1800'
- series: 'cve_critical_entschieden'
values: '63+0x1800'
- series: 'cve_entscheidungen_abgelaufen'
values: '0+0x1800'
- series: 'cve_entscheidungen_ohne_befund'
values: '0+0x1800'
- series: 'cve_entscheidungen_lesefehler'
values: '0+0x1800'
alert_rule_test:
- eval_time: 26h
alertname: CveCriticalOffen
exp_alerts: []
- eval_time: 26h
alertname: CveEntscheidungAbgelaufen
exp_alerts: []
- eval_time: 26h
alertname: CveEntscheidungOhneBefund
exp_alerts: []
- eval_time: 26h
alertname: CveEntscheidungenUnlesbar
exp_alerts: []
# --- CveZielmengeEingebrochen (#0051, Vorfall 2026-08-23) ------------------
- interval: 1m
name: "Ein Einbruch der Zielmenge faellt auf"
input_series:
# Der echte Verlauf: 54 Ziele, dann faellt die Cluster-Quelle still aus.
- series: 'cve_targets_desired_total'
values: '54+0x120 12+0x120'
alert_rule_test:
- eval_time: 1h
alertname: CveZielmengeEingebrochen
exp_alerts: [] # noch bei 54
- eval_time: 2h5m
alertname: CveZielmengeEingebrochen
exp_alerts: [] # eingebrochen, aber noch in der Karenz
- eval_time: 2h30m
alertname: CveZielmengeEingebrochen
exp_alerts:
- exp_labels:
severity: warning
room: security
exp_annotations:
summary: "Die CVE-Zielmenge ist auf 12 Ziele eingebrochen — ueber 40 % weniger als in den letzten 24 h. Faellt eine Quelle still aus, sieht das Dashboard danach BESSER aus, nicht schlechter."
# ⚠️ Gegenprobe zum Zusammenspiel: die vier aelteren Regeln schweigen in
# genau dieser Lage. Deshalb brauchte es eine fuenfte.
- eval_time: 2h30m
alertname: CveTargetsNoneDesired
exp_alerts: []
- eval_time: 2h30m
alertname: CveTargetsMissing
exp_alerts: []
- interval: 1m
name: "Ein kleiner Rueckbau bleibt still"
input_series:
- series: 'cve_targets_desired_total'
values: '54+0x60 44+0x180' # -19 %, unter der Schwelle
alert_rule_test:
- eval_time: 3h
alertname: CveZielmengeEingebrochen
exp_alerts: []
@@ -0,0 +1,52 @@
# Recording Rules fuer den Game-Host (management #0002).
#
# Materialisiert die Verknuepfung Container-UUID -> Klarname. cAdvisor kennt
# Gameserver nur unter ihrer UUID, weil Wings die Container so benennt und kein
# Namens-Label setzt. Relabeling kann das nicht aufloesen - es kann keine zweite
# Metrik nachschlagen. Diese Regeln schreiben deshalb alle 30 s Serien fort, die
# den Klarnamen bereits mitbringen, damit ihn nicht jedes Panel neu joinen muss.
#
# label_replace setzt zusaetzlich "display" auf den Klarnamen. Damit bedeutet
# display in JEDER Quelle dasselbe - in Loki, in den cAdvisor-Serien und hier:
# der Name, den Menschen lesen sollen.
#
# Uebernommen aus der Uebergabe des Game-Host-Betreibers vom 2026-08-20; dort
# gegen Prometheus 3.0.0 mit `promtool check rules` geprueft.
groups:
- name: gameserver
interval: 30s
rules:
- record: gameserver:cpu_cores
expr: |
label_replace(
sum by (uuid) (rate(container_cpu_usage_seconds_total{uuid!=""}[2m]))
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
"display", "$1", "server_name", "(.*)")
- record: gameserver:memory_working_set_bytes
expr: |
label_replace(
sum by (uuid) (container_memory_working_set_bytes{uuid!=""})
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
"display", "$1", "server_name", "(.*)")
- record: gameserver:memory_limit_bytes
expr: |
label_replace(
max by (uuid) (container_spec_memory_limit_bytes{uuid!=""} > 0)
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
"display", "$1", "server_name", "(.*)")
- record: gameserver:network_receive_bytes_per_second
expr: |
label_replace(
sum by (uuid) (rate(container_network_receive_bytes_total{uuid!=""}[2m]))
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
"display", "$1", "server_name", "(.*)")
- record: gameserver:network_transmit_bytes_per_second
expr: |
label_replace(
sum by (uuid) (rate(container_network_transmit_bytes_total{uuid!=""}[2m]))
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
"display", "$1", "server_name", "(.*)")
+141 -3
View File
@@ -1,6 +1,16 @@
global: global:
scrape_interval: 15s scrape_interval: 15s
rule_files:
- /etc/prometheus/alerts.yml
# Join Container-UUID -> Klarname, einmal vorweggenommen (#0002).
- /etc/prometheus/gameserver-rules.yml
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
# Zusaetzlich zu diesen Scrape-Targets kommen Metriken per Remote-Write rein # Zusaetzlich zu diesen Scrape-Targets kommen Metriken per Remote-Write rein
# (k3s-Cluster: flux, kube_state_metrics; Matrix-Server: synapse). # (k3s-Cluster: flux, kube_state_metrics; Matrix-Server: synapse).
scrape_configs: scrape_configs:
@@ -18,6 +28,14 @@ scrape_configs:
static_configs: static_configs:
- targets: ["cadvisor:8080"] - targets: ["cadvisor:8080"]
# Alertmanager-Eigenmetriken. Ohne diesen Job sind ZUSTELLfehler unsichtbar:
# Prometheus kennt alertmanager zwar als Alarm-Ziel (oben unter alerting:),
# scrapt ihn aber nicht - eine still reissende Alarmkette waere also genau das,
# was niemand meldet. Liefert u.a. alertmanager_notifications_failed_total.
- job_name: "operating_alertmanager"
static_configs:
- targets: ["alertmanager:9093"]
- job_name: "operating_traefik" - job_name: "operating_traefik"
metrics_path: "/metrics" metrics_path: "/metrics"
static_configs: static_configs:
@@ -30,12 +48,132 @@ scrape_configs:
labels: labels:
instance: "k3s-host" instance: "k3s-host"
# ==========================================================================
# Game-Host, privat ueber den vSwitch (management #0002).
#
# Bis 2026-08-20 zeigten die Targets auf 157.90.155.206 und waren seit dem
# ersten Scrape nie up. Ursache war nicht die Firewall, sondern: Die
# Exporter-Container veroeffentlichten ihre Ports gar nicht.
#
# Host-Seite deployt und verifiziert am 2026-08-20 (Uebergabe des Game-Host-
# Betreibers). Ports auf 10.0.0.4, NICHT auf 0.0.0.0 - letzteres umginge ufw.
# cadvisor liegt host-seitig auf 8081, weil coolify-proxy 0.0.0.0:8080 haelt.
#
# metric_relabel_configs gehoeren immer dem SCRAPENDEN Prometheus. Der
# Game-Host hatte sein Label-Konzept lokal; mit dem Rueckbau seines eigenen
# Prometheus wandert es hierher, sonst faellt es ersatzlos weg.
#
# instance = "gameserver" statt "pterodactyl": Damit tragen Metrik und Log
# denselben Schluessel - promtail setzt host="gameserver". Kostet keine
# Historie, die Targets sind am 2026-08-20 zum ersten Mal ueberhaupt up
# gegangen.
# ==========================================================================
- job_name: "pterodactyl_host_node" - job_name: "pterodactyl_host_node"
static_configs: static_configs:
- targets: ["157.90.155.206:9100"] - targets: ["10.0.0.4:9100"]
labels: labels:
instance: "pterodactyl" host: gameserver
relabel_configs:
- target_label: instance
replacement: gameserver
metric_relabel_configs:
# node-exporter meldet als nodename die UTS-Kennung SEINES Containers.
# Betrifft nur node_uname_info und repariert $nodename in "Node Exporter
# Full". Der Host setzt zusaetzlich hostname: gameserver - beides zusammen
# ist widerspruchsfrei.
- source_labels: [nodename]
regex: '.+'
target_label: nodename
replacement: gameserver
- job_name: "gameserver_cadvisor" - job_name: "gameserver_cadvisor"
static_configs: static_configs:
- targets: ["157.90.155.206:8080"] - targets: ["10.0.0.4:8081"]
labels:
host: gameserver
relabel_configs:
- target_label: instance
replacement: gameserver
metric_relabel_configs:
# 1) Gameserver-Container heissen nur nach ihrer UUID. In ein eigenes
# Label kopieren, damit der Join auf pterodactyl_server_info moeglich
# wird - siehe gameserver-rules.yml.
- source_labels: [name]
regex: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'
target_label: uuid
replacement: '$1'
# 2) "display" = der Name, den Menschen lesen sollen. Zwei Stufen: roher
# Container-Name, dann ohne die 24-stellige Coolify-Kennung.
# "homepage-pqr9de7xmm8s5cpm2yss69s3" -> "homepage"
- source_labels: [name]
regex: '(.+)'
target_label: display
replacement: '$1'
- source_labels: [name]
regex: '(.+)-[a-z0-9]{24}'
target_label: display
replacement: '$1'
# 3) Wings setzt an Gameserver-Containern gar keine Docker-Labels - ohne
# diese zwei Regeln blieben sie als einzige ohne app/stack.
- source_labels: [name]
regex: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
target_label: app
replacement: 'gameserver'
- source_labels: [name]
regex: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
target_label: stack
replacement: 'pterodactyl'
# 4) Sprechende Kurzlabels. Die Coolify-Regel steht jeweils HINTER der
# Compose-Regel und ueberschreibt sie: com.docker.compose.project
# liefert die Kennung, coolify.resourceName den sprechenden Namen.
- source_labels: [container_label_com_docker_compose_service]
regex: '(.+)'
target_label: app
replacement: '$1'
- source_labels: [container_label_coolify_serviceName]
regex: '(.+)'
target_label: app
replacement: '$1'
- source_labels: [container_label_com_docker_compose_project]
regex: '(.+)'
target_label: stack
replacement: '$1'
- source_labels: [container_label_coolify_resourceName]
regex: '(.+)'
target_label: stack
replacement: '$1'
# 5) Rund 20 container_label_* je Serie (Image-Hashes, Traefik-Router,
# Pfade) fliegen raus.
- regex: 'container_label_.*'
action: labeldrop
# 6) container_*-Serien ohne name sind systemd-cgroup-Slices, die
# node-exporter ohnehin abdeckt. machine_*-Metriken bleiben erhalten,
# deshalb muss __name__ hier mitgreifen.
- source_labels: [__name__, name]
regex: 'container_.+;'
action: drop
# Port 9810, NICHT 9531: Der bisherige Exporter (loens2) auf 9531 hat nie
# funktioniert - er rief die Client-API mit einem Application-Key auf und ueber
# http statt https und lieferte deshalb null pterodactyl_*-Metriken. Ersetzt
# durch einen Exporter, der die Panel-MariaDB read-only liest und gar keinen
# API-Key braucht.
- job_name: "pterodactyl_exporter"
static_configs:
- targets: ["10.0.0.4:9810"]
labels:
host: gameserver
relabel_configs:
- target_label: instance
replacement: gameserver
# CVE-Exporter (gitops#47)
- job_name: "cve_exporter"
static_configs:
- targets: ["cve-exporter:9101"]