Commit Graph
461 Commits
Author SHA1 Message Date
vulncheck 88a6bd47cc feat(audit): log asset renames done by a sync
A manual rename under Assets is audited, a sync-driven one left no trace:
Netdisco, vCenter, IGEL and Intune adopt the name the source reports once
they matched an asset on a pin, and the old name, still in a ticket or a
mail, then pointed at nothing.

All five rename spots now go through asset_lifecycle.rename, the rename
counterpart of apply_status. It logs "<source> sync renamed asset <id>:
old → new" at INFO and writes an ASSET_UPDATED audit entry "Asset renamed
by <source> sync: old → new", old_value the old name, new_value JSON with
hostname, old_hostname and source so the Asset column shows the new name.
Wazuh, Nessus, Defender and the container imports never rename.
2026-09-18 09:34:33 +02:00
vulncheck 4f1f750658 fix(loadmaster): long-term fixes from the release notes where no record names them
A Kemp LoadMaster 7.2.48.0 in a test showed 15 findings but not CVE-2024-7591
(CVSS 10): both its CNA record (< 7.2.60.1) and NVD (<= 7.2.60.0) name only
the GA fix, and loadmaster_fix deliberately gives a long-term build no verdict
from a GA-only range. The same gap hides CVE-2025-1758 and
CVE-2026-3517/-3518/-3519/-4048 on every 7.2.48 and 7.2.54 box, and
CVE-2024-2448/-2449/-3544 everywhere: their CNA bounds carry labels no reader
parses ("7.2.54.9 ( LoadMaster LTSF)") and NVD files them under
a:progress:loadmaster, which the o: query never returns.

_LM_BULLETIN holds (start, fix) per line for those CVEs, read off the Progress
release notes (Security Updates of 7.2.48.11/.12, 7.2.54.9/.10/.12/.13/.14/.17).
A CVE with a row is decided by the row alone, in the cvelistV5 and the NVD
pass, through the existing per-line rules. Lines the notes do not name stay
out, so a 7.1.35 box gets no verdict where the sources disagree. 7.2.48.0 now
also shows 7591 (fix 7.2.54.12), 1758 (7.2.54.14) and the four 2026 CVEs
(7.2.54.17); 2448/2449/3544 start at 7.2.48.10/.11 and stay off it.
2026-09-17 15:22:53 +02:00
vulncheck 6da07b3630 feat(dockhand): rescan every image before the nightly import
Dockhand has no scan schedule: it scans on pull, on update and by hand, so an
image that is never pulled keeps its first scan and new CVEs in its packages
never reach the export. With "Rescan nightly" (scan_nightly, default off) the
02:50 UTC job calls POST /api/vulnerabilities/scan-all per environment
(Dockhand >= 1.0.37, synchronous with Accept: application/json, 3 h read
timeout) before reading that environment's export. A failed or partial rescan
lands in the run's errors and does not abort; the export still holds the
previous scan. Sync now never rescans. The token needs images:inspect.

Docs and the settings card name Dockhand's Grype arguments
`-o json --by-cve -v {image}`; the default lacks --by-cve and GHSA ids do not
merge with other sources' CVEs.
2026-09-17 12:22:53 +02:00
vulncheck e91867dfc2 feat(dockhand): pull container image findings from Dockhand
Settings → Integrations → Dockhand (URL, dh_ API token, severity floor,
only-fixable). Nightly at 02:50 UTC or Sync now. Reads /api/environments and
each environment's /api/vulnerabilities/export (Dockhand >= 1.0.37) and feeds
every image through the container import, source "dockhand".

The same image on several hosts is one asset and retracts against the union
of all environments; a failing environment aborts before anything is written.
When Dockhand cannot reach a Docker host it exports stale scans of deleted
images while /api/images answers [] — findings without images abort the sync
instead of reviving them. Untagged images and rows without an id are skipped.

Container import: a CVE on a known-exploited catalog (CISA KEV, ENISA EUVD,
VulnCheck KEV) now passes the severity and only-fixed filters (kev_kept), so
enrichment flags it and the KEV alert fires. ingest_report is split into
parsing and ingest_image so both sources share one merge and retraction.
2026-09-17 11:47:29 +02:00
vulncheck 2401196c7e feat(container): import Trivy / Grype image scans
POST /api/v1/vulnerabilities/sync/container takes one Trivy or Grype JSON
image report. One asset per image (normalised repo:tag, source CONTAINER);
findings merge on (cve_id, asset_id) with sources trivy / grype, and GHSA or
distro advisory ids resolve to the CVEs the report itself names, so both
scanners dedupe instead of double-counting.

Ingest filter (min_severity, only_fixed) is server-side; retraction uses the
whole report, so a CVE hidden by the filter never closes as patched, and an
empty report retracts nothing. The first report of an image repository is a
silent baseline (new vulnerabilities.baseline column, Alembic 059): no
new-finding mail, but SLA and KEV alerts still apply. A new tag of a known
repository notifies.

tools/container_scan_push.sh scans every running image on a Docker host and
pushes the reports.
2026-09-17 11:20:43 +02:00
vulncheck 9fc9c24a45 feat(sophos): name missing advisory rows in the run errors, query two NVD names
A Sophos Firewall CVE from 2021 on without an _SFOS_ADVISORIES row gets no
verdict, and until now only a log warning said so. The next SFOS advisory would
have been silently skipped. Those CVEs are now collected per run and added as
one line to the errors of the Netdisco sync (sync-run ledger) and of the
app-CVE scan: "sophos: no _SFOS_ADVISORIES row for CVE-… — no verdict until the
advisory's hotfixed releases are added". Pre-2021 CVEs stay debug-only.

NVD is queried under firewall_firmware and a:sophos:firewall only, two calls
per build instead of five. o:sophos:firewall holds only CVE-2022-1807
(cvelistV5 has it); sfos and xg_firewall_firmware only pre-2021 CVEs that no
row covers. a:sophos:firewall stays for CVE-2023-5552, which cvelistV5 lists
as unaffected entries only.
2026-09-16 15:50:21 +02:00
vulncheck c23611dae6 feat(netdisco): Sophos Firewall firmware CVEs
Netdisco reports a Sophos Firewall as vendor "sophos", os "sfos", version
"22.0.1 MR-1-Build490". It now becomes asset OS "Sophos Firewall" with that
string, read as 22.0.1.490, and is scanned from NVD and cvelistV5 like the
other seven firmware lines. EAP builds and GA/MR labels that contradict the
release are not read.

Both sources bound the MR (CVE-2025-6704: NVD < 21.0.2, CNA "21.0 MR2
(21.0.2)"). Sophos hotfixes older releases first (19.0 MR2 .. 21.5 GA for
6704), installs hotfixes automatically by default, and the firmware version
does not change, so the range alone flags patched firewalls. The advisories
are the only source naming hotfixed releases, HTML only (no CSAF, RSS carries
titles). They are not scraped: _SFOS_ADVISORIES holds per CVE the releases
with the fix and the hotfixed releases, from every SFOS advisory 2022-2025.
sophos_fix turns a source match into a finding only if the CVE has a row,
the box's MR got no hotfix and no fix on its own line is at or below it.
That also settles NVD's single "<= 19.0.1" on CVE-2022-3236 against the
18.5 MR5 fix. A CVE without a row gets no verdict and a warning.

NVD: firewall_firmware plus the older names a:firewall, o:firewall, sfos
and xg_firewall_firmware. cvelistV5: Sophos / "Sophos Firewall" (index v38);
SG UTM and the client products are not matched. Checked against the live
sources: 22.0.1.490, 21.5 GA and 21.0 MR1-2 are clean; 20.0 MR1 gets
6704/7382/7624/13973/13974.
2026-09-16 14:27:40 +02:00
vulncheck 685b26ca02 feat: show the vendor's own severity when it differs from CVSS
Vendors rate by their own scale and can disagree with the CVSS band the
severity badge follows: CVE-2026-85921 is CVSS 8.2 (High) at NVD and
cvelistV5, Critical at MSRC. Chrome and Firefox already showed their
rating next to ours; every other source that states one dropped it.

vulnerabilities.vendor_severity (Alembic 058) holds the ratings per
vendor as JSON, written through Vulnerability.set_vendor_severity:

- Microsoft: the highest CVRF Type-3 Threat ("Max Severity"), or the
  Update Guide's severity on the per-CVE path, stamped on every finding
  with that CVE id whatever scanner found it
- TeamViewer: the bulletin's Priority (the "(High)" beside the score is
  only the CVSS band)
- GitHub repo advisories: the advisory's severity, labelled with the repo
- Android ASB and Samsung SMR: the bulletin's rating

New findings merge their siblings' ratings, so a finding for an older
CVE does not wait for the Sunday 18-month MSRC ingest. IGEL is left out:
its only rating is the CVSS band.

The detail page shows "<vendor>: <rating>" only where it maps to a
different band (Important=high, Moderate=medium). A column rather than a
note in the description, as Mozilla does: M365 and Nessus rewrite the
description nightly.
2026-09-15 20:38:44 +02:00
vulncheck aa8770e13b feat(netdisco): Kemp LoadMaster firmware CVEs
Netdisco reports a LoadMaster as vendor "Kemp", os "LoadMaster", version
"7.2.63.3.23e1735.RELEASE.20260625-1143". It now becomes asset OS "Kemp
LoadMaster" / "7.2.63.3" and is scanned from NVD and cvelistV5 like the other
six firmware lines. Nothing from the vendor bulletins is ingested.

GA (7.2.55 .. 7.2.63), LTSF (7.2.54.x, EOL 7.2.48.x) and Multi Tenant
(7.1.35.x) share one numbering, and each advisory names one fix per line.
Both sources write those as independent ranges that overlap: the CNA's
"7.0.8 < 7.2.63.3" contains a patched LTSF 7.2.54.19, NVD's "<= 7.2.48.12"
a patched Multi Tenant 7.1.35.13. loadmaster_fix decides per line for both
paths: a range ending on the build's own line decides alone; a 7.1.35 build
gets no other verdict; a GA build takes any containing range; an LTSF build
only a range ending on a later long-term line. A record naming only the GA
fix (CVE-2024-6658's CNA entry) gives an LTSF box no verdict.

NVD files the 7.1.35 fixes under multi-tenant_loadmaster/_hypervisor; those
criteria are read into the decision. cvelistV5 matches "LoadMaster" and
"Multi Tenant (LoadMaster)" under Progress / Progress Software (index v37);
ECS Connection Manager, ObjectScale Connection Manager and MOVEit WAF are not.

CVE-2026-8037: the CNA starts the GA range at 7.2.60.0, NVD at 7.2.55.0, the
bulletin says "GA v7.2.63.1 and all prior". Findings are the union.
CVE-2026-33691 from the same bulletin is an OWASP CRS record without
LoadMaster and is not detected.
2026-09-15 14:24:44 +02:00
vulncheck dd278a27da feat(netdisco): NetScaler ADC / Gateway firmware CVEs
Netdisco reports a NetScaler as vendor "citrix", os "netscaler", version
"14.1-72.61". It now becomes asset OS "NetScaler ADC" with that build and is
scanned from NVD and cvelistV5 like the other five firmware lines. Nothing
from the vendor bulletins is ingested.

cvelistV5: the CNA names the release line in `version` and the build within
it in the bound ("14.1" < "43.56"). The generic range reader would read that
as 14.1 <= x < 43.56 and flag every appliance, so the entries are indexed raw
(index v36) and decided by scan_asset_netscaler, which applies only the entry
for the appliance's own line. CVE-2026-19489/-19490 write lessThanOrEqual
"73.32" where bulletin CTX696939 and NVD's analysis say before 73.32. It is
read as lessThan, so a patched appliance is clean and the finding can close.
Both spellings of the CNA (Citrix / "NetScaler ADC", NetScaler / "ADC") are
matched; NetScaler Console, which also files "14.1", is not.

NVD: netscaler_application_delivery_controller and netscaler_gateway. The
fips/ndcpp sw_edition entries carry their own bounds and virtualMatchString
does not filter on them. Verified live, a patched 14.1-73.32 still got
CVE-2026-19490 from the fips entry "through 14.1-73.32". The new
skip_editions registry field drops them.

FIPS and NDcPP builds are separate lines the inventory cannot see. On 13.1
and 12.1 they sit on build 37.x and 55.x, below every normal fix, so those
builds and any model or version naming FIPS/NDcPP get no verdict. Some
records omit the FIPS line the bulletin lists, so the record alone cannot
gate it.
2026-09-15 13:08:23 +02:00
vulncheck 83c524120f fix(defender): never generalise a Windows component label to the OS
Field check CVE-2026-69414: Defender labels the finding
"microsoft windows_defender 1.1.26070.7", which must stay that exact.
d708a04 skipped the placeholder repair for any export product starting with
"windows", so a "Microsoft Windows" placeholder whose export later named
windows_defender kept the generic OS string for good.

The heuristic is gone. Defender's own label always replaces its OS
placeholder, since it is the more exact name, OS or not. The one exception
is a row MSRC also claims: resolve_stale_os finds a finding by the asset's
OS string, and renaming it would leave MSRC no way to close it.

Rows that already carry Defender's exact label are untouched, as before.
The heal and fill logic only acts on the OS placeholder or an empty column.
2026-09-15 12:58:00 +02:00
vulncheck d708a04481 fix(defender): a Chrome or Edge CVE was labelled "Microsoft Windows"
Field report CVE-2026-87474: first seen by Defender TVM, the finding showed
package "Microsoft Windows", installed 10.0.26100.9445 (the Windows build)
and fixed in 153.0.8010.36 (Chrome). Rows whose CVE another scanner found
first were correct.

Cause: the tenant-wide SoftwareVulnerabilitiesByMachine export lags the
per-machine /vulnerabilities call, so a CVE published that day reaches the
device before the export names its software. 8ce476c falls back to the
asset's OS and os_version whenever the label is missing, which was meant for
devices the export does not cover (the iPhones of CVE-2026-64726). The
fill-only update then kept the placeholder once the export caught up; when
MSRC later added Edge as a package row, the detail page showed Edge while the
list kept "Microsoft Windows".

The OS fallback now applies only to a machine that has no row in the export
at all, and not when the export is empty. A covered machine with no label
yet gets an empty package, which the next sync fills. On a row Defender
created, its own OS placeholder is replaced once the export names an
application; OS products (windows_*, or the asset's own OS name) keep it.

Rows Defender no longer reports, like the ones MSRC took over, keep the old
label. This change does not rewrite them.
2026-09-15 11:16:56 +02:00
vulncheck 21e34a58ef fix(assets): a thin client and a UniFi AP took turns on one asset every night
Field report 2026-09-15, four rows in a live estate:

1./2. A UniFi AP carried an igel_unit_id and a netdisco_device_ip. The IGEL
   sync (02:30) wrote a thin client's name, IGEL OS and its findings onto the
   row, the Netdisco sync (02:40) wrote the AP back over it. The merge came in
   over a shared address before c73d062; it could still come in over the
   name, because the hostname stage takes any asset without a pin of its own
   source. A thin client is never a Netdisco device and vice versa, so the
   IGEL and Netdisco chains now pass over assets that carry the other
   source's pin, at the name and the address stage
   (asset_matching.not_pinned_by). Consolidation with Wazuh, Nessus and
   friends is unchanged.

3./4. A Windows server and a pre-053 re-IP fork still held the
   netdisco_device_ip of an AP that had long moved. The netdisco-ip stage
   took such a row for whatever device got the address next, decommissioned
   rows included, so the new device inherited the old one's findings or
   vanished behind a retired row. The stage now skips a row when the device
   reports a serial the row does not carry AND a different name
   (_other_hardware). Stack members share the name, a renamed switch without
   a serial reports none; both still match.

Rows that already hold two devices are not split. Delete them and both syncs
recreate their own; README.DEV says so. A serial-less device on a stale pin
still cannot be told apart.
2026-09-15 09:41:17 +02:00
vulncheck 40f08b5fff fix(assets): die Namensstufe riet bei Namensvettern, und "_" traf "-"
Nach c73d062, 40e9c29, 0936cf4 und a8961ec fuehrte ein Name an vier Stellen
trotzdem auf das Asset eines anderen Geraets — Findings am falschen Host:

1. `hostname.ilike(name)` ohne Escaping. `_` ist in LIKE ein Platzhalter,
   "PC_LAB" fand "PC-LAB". Betraf Nessus, Netdisco, vCenter, IGEL, Intune
   (match_by_hostname) und Defender.
2. Mehrere gleichnamige Assets ohne Pin der Quelle: die Stufe nahm das mit
   der kleinsten id. Zwei "srv01" in zwei Domaenen, Nessus scannt das zweite
   und schreibt aufs erste; Netdisco vertauscht beide dauerhaft.
3. Defender dasselbe ganz ohne Schutz. Seit Intune gleichnamige Geraete als
   eigene Assets fuehrt, gibt es die Namensvettern dort wirklich; eine
   Maschine ohne aadDeviceId landete auf dem erstbesten.
4. Der Wazuh-Cluster-Node (Werksname "node01") uebernahm ueber den Namen das
   Asset eines echten Agents und ueberschrieb dessen Agent-ID — Flip-Flop mit
   dem Agent-Sync bei jedem Lauf.

Dazu wies das manuelle Anlegen einen vergebenen Hostnamen mit 400 ab, das
zweite Geraet liess sich nicht fuehren — dieselbe Annahme, die c73d062 fuer
die IP-Adresse schon gestrichen hat.

Die Ursache war in allen Faellen dieselbe Annahme: bleibt nach dem Pin-Guard
mehr als ein Kandidat, sei der erste schon richtig. services/asset_matching
.pick_one entscheidet jetzt: ein Kandidat ist der Treffer, bei mehreren die
Adresse, die die Quelle meldet; entscheidet sie nicht, gibt es keinen Treffer
und die Quelle legt ein eigenes Asset an. Eine sichtbare Dublette statt einer
Vermischung. Wazuh-Agent-Stufe und Defender gehen durch dieselbe Regel; der
Re-Image-Fall bei Defender (ein Asset, zwei Maschinen) und die
Neuregistrierung bei Wazuh bleiben unberuehrt, dort gibt es nur einen
Kandidaten. Der Cluster-Node nimmt nur Assets ohne Agent-ID.

Bestehende vermischte Zeilen loest das nicht auf; tools/find_merged_assets.py
bleibt der Weg dorthin.
2026-09-14 18:22:42 +02:00
vulncheck 0551af0f92 feat(vcenter): ESXi-Hosts tragen jetzt ihre Hardware-Seriennummer
Der vCenter-Sync fragte aus `hardware.systemInfo` nur uuid, vendor und
model ab. Die Seriennummer steht im selben Objekt und wurde nie gelesen;
am Asset hing die VMware-UUID, aber nichts, was man vom Gehaeuse ablesen
oder dem Hersteller nennen kann.

Gelesen wird `serialNumber`, bei leerem Feld oder Platzhalter die
SMBIOS-Kennungen SerialNumberTag, EnclosureSerialNumberTag und ServiceTag
(Dell meldet die Service-Tag nur dort). AssetTag bleibt aussen vor, das ist
Freitext aus dem BIOS. Platzhalter fallen ueber stable_serial weg. Die Serial
steht in assets.vmware_serial (056), im Edit-Dialog als "ESXi host serial"
und als letzte Spalte im CSV-Export.

Kein Match-Schluessel: der Host wird weiter ueber die UUID gefunden, die
Zuordnung aendert sich nicht. Meldet ein spaeterer Sync keine Serial, bleibt
die bekannte stehen.

Die PropertyCollector-Abfrage holt jetzt `hardware.systemInfo` als Ganzes
statt einzelner Blaetter. `serialNumber` gibt es erst ab API 6.7, und ein
Pfad, den der Server nicht kennt, laesst den kompletten RetrieveContents
scheitern, fuer alle Hosts.

Die VMware-UUID im Edit-Dialog kam bereits mit dd82b85.

Kein Backfill: der naechste vCenter-Sync stempelt die Serial.
2026-09-14 11:33:59 +02:00
vulncheck dd82b85a31 fix(igel): kein IGEL-Asset trug eine Seriennummer
Der IGEL-Sync las aus `?facets=details` nur `unitID`. IMI liefert dort
`unitID` und `mac` mit demselben Wert, der MAC. Die Herstellerseriennummer
steht als `deviceSerialNumber` im selben Payload und wurde nie gelesen. `id`
ist die UMS-Datenbankzeile (neu bei jeder Registrierung), `assetID` ein
meist leeres Freitextfeld; beide identifizieren keine Hardware.

Die Seriennummer steht jetzt in assets.igel_serial (055) und matcht nach der
Unit-ID, vor dem Namen. Das faengt den Fall, den die MAC nicht ueberlebt:
Board- oder NIC-Tausch, UD Pocket neu registriert. Hostname und Unit-ID
ziehen dann nach, die Finding-Historie bleibt am Asset.

Die Serial uebernimmt ein Asset nur, wenn dessen Unit-ID in DIESEM Lauf
nicht mehr vorkommt. Zwei UD Pockets auf demselben PC melden dieselbe
Host-Serial und sind trotzdem zwei Geraete; ohne den Guard zoege die zweite
das Asset der ersten an sich (dieselbe Regel wie beim Wazuh-Agent, 0936cf4).
Platzhalter-Serials fallen ueber stable_serial weg.

Unveraendert und jetzt im Test festgenagelt: Umbenennen in UMS behaelt das
Asset (Unit-ID-Match); neue Hardware unter dem Namen eines verschwundenen
Geraets bekommt ein eigenes Asset, das alte wird INACTIVE.

Sichtbar war keiner der Pins. Der Edit-Dialog zeigt jetzt neben Entra,
Intune, Defender und Wazuh auch Intune-Serial, Nessus-UUID, VMware-UUID,
IGEL-Unit-ID und -Serial sowie Netdisco-IP und -Serial; der CSV-Export
haengt die fehlenden Spalten hinten an, damit bestehende Spaltenpositionen
bleiben.

Kein Backfill: der erste Sync nach 055 stempelt die Serial ueber die
Unit-ID. Bereits geforkte Assets werden nicht zusammengefuehrt.
2026-09-14 11:24:20 +02:00
vulncheck 2214af1d64 fix(cvelistv5): Citrix Workspace app CVEs never reached a Windows host
Two faults, either one enough on its own. The inventory filter dropped every
package whose vendor contains "citrix", which was meant for the "Delivered by
Citrix" published-app stubs but also removed every real install ("Citrix
Workspace 2507", vendor "Citrix Systems, Inc."). And behind it, Citrix states
its fixes as release names, not builds: CVE-2026-78546/-78547 give
"2603.11 Current Release (CR)", "2507.1 LTSR CU3" and "LTSR 2607". Read as
digits, "LTSR 2607" is (2607,), so every 25.x/26.x build would have matched,
the patched CU3, CR 2603.11 and LTSR 2607 hosts included.

The inventory states only the build (25.7.1000.1025), with no CR/LTSR and no
CU. Both sides now meet on the build via a catalog read from Citrix's download
pages (title + "Version:"), kept only when the build agrees with its title. Five
CR pages print a sidebar build (22.12.0.48) first, and without that check
they would have entered the catalog as 2302..2307.1. The catalog is seeded with
44 pages and refreshed weekly by the 01:30 index job.

CR and LTSR never share a year.month, so the build's first two fields name the
branch. The decision reuses vmware_release_service.is_affected: CR is one
line, each LTSR is its own line with the CU as update line (CVE-2025-4879 fixes
2402 in CU2 HF1 and CU3 HF1). A bound that resolves to no single catalog build
leaves the CVE undecided and held open, never guessed. Windows hosts only; the
Mac app shares the name and its 25.07.x numbering.
2026-09-14 11:14:00 +02:00
vulncheckandClaude Opus 5 a8961ec8fe fix(assets): der Intune-Sync warf namensgleiche Geraete weg
`_dedupe_reenrolled` fasst alle managedDevices mit demselben deviceName
zusammen, bevor irgendetwas ein Asset anfasst. Fuer den Fall, fuer den die
Funktion geschrieben wurde, ist das richtig: ein gewischtes und neu
angemeldetes Geraet steht zweimal in Intune, beide Eintraege matchten dasselbe
Asset und pinnten abwechselnd ihre eigene device id darauf — der Lauf endete
auf der TOTEN Anmeldung und las deren eingefrorene App-Liste.

Der Schluessel war aber der Name allein, und ein Name ist kein Identifikator.
Ein Windows-Standardname (DESKTOP-XXXXXXX), eine Charge namensgleicher
Android-Geraete: das unterlegene Geraet fiel komplett aus dem Lauf. Kein
Asset, kein Inventar, keine Findings — und nichts in der UI, das sagt, dass
ein System fehlt. Eine Abdeckungsluecke wiegt hier schwerer als eine
sichtbare Dublette: ein Geraet, das gar nicht erst auftaucht, wird auch nicht
vermisst.

Die Hardware-Seriennummer trennt die beiden Faelle und Graph liefert sie im
selben Payload (`serialNumber`, schon im $select). Eine Neuanmeldung behaelt
sie, zwei Geraete teilen sie nicht. Sie steht jetzt in assets.intune_serial
und matcht vor dem Namen — dieselbe Kette wie beim Netdisco-AP (053), aus
demselben Grund: der stabile Identifier der Quelle (hier die device id) ist
eine Anmeldungs-Tatsache und wechselt, die Hardware nicht.

Der Pin fuer die Namensstufe ist deshalb die Serial und NICHT die device id:
auf die device id zu pinnen wuerde jedes neu angemeldete Geraet forken, also
genau den Fall zerstoeren, fuer den die Stufe existiert. Geraete, die keine
Seriennummer melden, verhalten sich unveraendert. Ein Asset ohne Serial bleibt
matchbar — Wazuh legt den Host an, Intune findet ihn ueber den Namen.

`stable_serial` liegt jetzt in services/asset_matching, weil zwei Ketten sie
brauchen; Platzhalter-Serials fliegen dort weiter vor dem Lookup raus, die
Liste ist um die drei DMI-Klassiker ergaenzt ("System Serial Number", "To Be
Filled By O.E.M.", "Default String").

Defender bleibt bewusst ungeschuetzt: ein neu aufgesetztes Geraet behaelt dort
seinen alten Maschineneintrag, mehrere Maschinen-IDs auf ein Asset sind der
Normalfall und der CVE-Abgleich ist darauf gebaut. Die Machines-API liefert
keine Seriennummer — es gibt nichts, was "neu aufgesetzt" von "zwei Hosts, ein
Name" trennt, und ein Guard auf die Maschinen-ID haette das lebende Geraet
nach jedem Reimage aus dem Scan geworfen. In README.DEV steht die Tabelle, wer
womit unterscheidet, und die Bedingung, unter der Defender nachzieht.

Kein Backfill — der erste Sync nach 054 stempelt die Serial ueber die device
id oder den Namen. Bereits zusammengefuehrte Assets werden nicht getrennt:
daran haengen Findings und Audit-Trail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:02:47 +02:00
vulncheckandClaude Opus 5 0936cf4ecd fix(assets): zwei Wazuh-Agents mit demselben Namen lagen auf einer asset_id
Dieselbe Regel wie in 40e9c29, eine Quelle weiter: der Wazuh-Sync fiel nach
der Agent-ID bedingungslos auf `Asset.hostname == name` zurueck. Zwei APC-PDUs
heissen ab Werk beide "RackPDU" — die zweite fand das Asset der ersten, nahm
ihm die Agent-ID weg und schrieb ihre Inventardaten darueber. Ein Geraet im
Inventar statt zwei, und welches es fuehrt, entschied die Reihenfolge des
Laufs.

Die Stufe abzuschalten waere hier der teurere Fehler gewesen: authd ersetzt
seit 4.3 per <force> einen Agent gleichen Namens (Default disconnected_time
1h, after_registration_time 1h), der Host meldet sich danach unter einer NEUEN
ID, und der Hostname ist der einzige Weg zurueck zu seiner Historie. Ein
Pin-Guard wie bei Netdisco/Nessus/vCenter/IGEL haette jede Neuregistrierung
auf eine zweite Zeile geworfen und die Findings an der ersten liegen lassen.

Unterschieden wird an der Frage, die der Sync ohnehin beantworten kann: kommt
die alte Agent-ID in DIESEM Lauf noch vor? Ein ersetzter Agent ist aus der
Liste verschwunden — Asset uebernehmen. Zwei Agents, die beide drinstehen,
sind zwei Hosts — jeder bekommt seine eigene Zeile. Der Konsolidierungsfall
bleibt unberuehrt: ein Asset ohne Wazuh-ID (aus Netdisco, Nessus, manuell)
sammelt der Agent weiter ueber den Namen ein.

Nebenbei die Reihenfolge festgenagelt (order_by): bei zwei gleichnamigen
Assets darf nicht der Query-Planer entscheiden, an welchem ein Agent haengen
bleibt.

Bestehende Zeilen loesen sich wie bei 40e9c29 von selbst auf, sobald beide
Agents in einem Lauf stehen — der zweite faellt durch die Namensstufe und
bekommt seine eigene. Rueckwirkend geteilt wird die Historie nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:02:20 +02:00
vulncheckandClaude Opus 5 f3d453d6e9 fix(assets): ein Geraet mit neuer IP legte ein zweites Asset an
Ein UniFi AP hat seine Firmware aktualisiert und dabei einen neuen
DHCP-Lease gezogen. Gleicher Hostname, gleiche Seriennummer, trotzdem zwei
Assets: das neue ACTIVE, das alte INACTIVE, weil kein Geraet mehr darauf
zeigte.

Netdisco war ausschliesslich auf die kanonische Adresse gepinnt — und der
Hostname-Fallback ebenfalls, ueber pin_col=netdisco_device_ip. Das Asset mit
der ALTEN Adresse fiel damit aus genau der Abfrage heraus, die es haette
finden sollen. Alle drei Stufen der Kette haengen an derselben Adresse, also
half keine.

Die Seriennummer ist der einzige Wert, der Rename und Re-Adressierung
ueberlebt, und Netdisco meldet sie pro Geraet — sie wurde nur in den
Beschreibungstext geschrieben. Sie steht jetzt in assets.netdisco_serial und
matcht vor der Adresse.

Der Pin wird einmal gesetzt und nie ueberschrieben: Stack-Mitglieder
(VSF/IRF) melden einen Namen und eine Adresse, aber je eine Seriennummer,
und sie bleiben ein logisches Geraet — das Asset behaelt die erste Serial,
die uebrigen Chassis matchen weiter ueber die Adresse. Namensvettern bleiben
zwei Assets (40e9c29 gilt unveraendert). Platzhalter-Serials ("N/A", Nullen,
alles unter vier Zeichen) identifizieren nichts und fliegen vor dem Lookup
raus: darauf zu matchen waere der Fund am falschen Host, schlimmer als der
Fork, den das hier behebt.

Kein Backfill — der erste Sync nach 053 trifft jedes Geraet ueber seine in
dem Moment unveraenderte Adresse und stempelt die Serial dort. Bereits
geforkte Assets werden nicht zusammengefuehrt: an beiden Zeilen haengen
Findings und Audit-Trail, das bleibt eine manuelle Entscheidung.

test_asset_duplicate_hostname prueft nicht mehr auf den Namen der
Match-Stufe: sie heisst jetzt netdisco-serial statt netdisco-ip, fuehrt aber
auf dasselbe Asset. Die Stufe war nie die Zusage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:15:07 +02:00
vulncheckandClaude Opus 5 40e9c29920 fix(assets): zwei Geraete mit demselben Namen lagen auf einer asset_id
Dieselbe Annahme wie bei der geteilten IP-Adresse (c73d062), eine Stufe
hoeher: jede Sync-Match-Kette faellt nach ihrem stabilen Identifier auf
`Asset.hostname.ilike(name)` zurueck, und diese Stufe war bedingungslos.

Zwei APC-PDUs heissen ab Werk beide "RackPDU" — ein Name, den niemand
aendert, bei manchen Geraeten mangels Zugriff auch gar nicht aendern kann.
Die erste legt das Asset an und pinnt ihre netdisco_device_ip darauf, die
zweite findet es ueber den Namen, ueberschreibt Pin und Adresse. Im Inventar
steht ein Geraet, und welche der beiden Adressen es fuehrt, entscheidet die
Reihenfolge des Laufs.

Ein Name ist kein Identifikator. services/asset_matching.match_by_hostname
laesst ihn nur noch matchen, wenn kein anderes Geraet derselben Quelle den
Namen bereits belegt: traegt ein gleichnamiges Asset schon einen anderen
netdisco_device_ip / nessus_host_uuid / vmware_uuid / igel_unit_id, ist es
ein Namensvetter und kein Treffer. Ein Asset ohne Pin bleibt matchbar —
sonst stirbt der Fall, fuer den die Stufe existiert: Wazuh legt einen Host
an, Netdisco findet dasselbe Geraet ueber den Namen. Alle vier Ketten
gehen jetzt durch dieselbe Regel, inklusive der FQDN-Praefix-Stufe.

Anders als beim IP-Fall loesen sich bestehende Zeilen von selbst auf: das
zweite Geraet faellt beim naechsten Lauf durch die Namensstufe und bekommt
seine eigene. Rueckwirkend geteilt wird die Historie nicht.

Ein Stack ist der Gegenfall und bleibt EIN Asset. VSF-Mitglieder melden
sich je Chassis unter demselben Namen und derselben Adresse — ein logisches
Geraet, eine Firmware, ein CVE-Satz; drei Assets waeren dreifache Findings
und eine falsche Zahl im Dashboard. Was fehlte: der Lauf schrieb nur die
zuletzt gemeldete Seriennummer ans Asset. Der Netdisco-Sync sammelt sie
jetzt je Asset ein ("serials SG01TQ, SG02HY, SG03J6"), damit am Geraet
steht, aus wie vielen Chassis es besteht.

Nebeneffekt, bewusst in Kauf genommen: ein Switch, der seinen Namen behaelt
und eine neue Management-Adresse bekommt, forkt auf eine zweite Zeile, die
alte faellt per Reconcile auf INACTIVE. Eine sichtbare Dublette ist besser
als zwei Geraete auf einer asset_id, die sich Status und Hostnamen
gegenseitig ueberschreiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:54:12 +02:00
vulncheckandClaude Opus 5 a43cbc7634 feat(settings): eine konfigurierte Integration hatte keinen Weg zurueck
Alle Felder leeren und speichern loescht nichts. Ein leer eingereichtes
Secret-Feld holt sich im Backend absichtlich den gespeicherten Wert zurueck
(damit Host und IDs editierbar sind, ohne Zugangsdaten neu zu tippen) -- beim
Zuruecksetzen ueberlebt damit das Secret. Und die leere Konfigurationszeile,
die uebrig bleibt, liest die Oberflaeche weiter als "gesetzt": das Badge blieb
gruen.

Reset-Knopf pro Integration, mit Sicherheitsabfrage, fuer alle acht (Wazuh,
Nessus, Intune, vCenter, IGEL, Netdisco, AI, SMTP; OpenRouter und GitHub-PAT
hatten ihren schon). Er schreibt einen leeren Wert -- kein gueltiges JSON, laeuft
also am Secret-Merge vorbei und loescht die Zeile fachlich. Kein eigener
DELETE-Endpoint noetig. Immer anklickbar, nicht nur bei "vollstaendig
konfiguriert": eine halb ausgefuellte Integration ist genau der Zustand, den
man aufraeumen will.

Sync Health kannte NOT CONFIGURED bisher nur fuer Quellen, die noch nie
gelaufen waren -- nach einem Reset lagen die alten sync_runs noch da, und die
Quelle waere auf STALE gefallen und rot geblieben, ohne dass je wieder etwas
laufen kann. Der Zustand folgt jetzt der Konfiguration, nicht der Historie; das
Ledger selbst bleibt unangetastet.

Importierte Assets und Findings bleiben. Eine Quelle nimmt nur ihre eigenen
Findings zurueck, und zwar im Lauf -- ein entfernter Konnektor laeuft nicht mehr
und kann nichts retracten. Alles wegzuraeumen wuerde Findings und Assets
treffen, die andere Quellen mittragen. Ein Purge pro Quelle ist ein eigener
Vorgang, nicht derselbe Knopf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:54:49 +02:00
vulncheckandClaude Opus 5 c73d062f5a fix(assets): eine geteilte IP-Adresse legte zwei Geraete auf eine asset_id
Jede Sync-Match-Kette (Netdisco, Nessus, vCenter, IGEL) endete bedingungslos
auf `Asset.ip_address == ip`. Ein abgeschalteter Wazuh-Agent und ein UniFi AP
teilten sich eine Adresse: der Netdisco-Sync fand darueber das Wazuh-Asset,
pinnte seine netdisco_device_ip darauf und beide Geraete hingen an derselben
Zeile. Ab da benannte Netdisco sie um, Wazuh setzte sie per Keepalive-Urteil
auf INACTIVE, Netdisco wieder auf ACTIVE — Deaktivierung und Reaktivierung
derselben asset_id bei jedem Lauf, dauerhaft.

Eine Adresse ist kein Identifikator. Gastnetze, VPN-Pools, VLAN-Segmente und
recycelte DHCP-Leases vergeben dieselbe IP mehrfach; ein Bestand, in dem das
nicht vorkommen darf, ist eine Annahme ueber das Netz des Kunden, keine ueber
unsere Daten. services/asset_matching.match_by_ip laesst die Adresse nur noch
matchen, wenn sie eindeutig ist und kein Name widerspricht. Der Fall, fuer den
die Stufe existiert — ein ESXi, den vCenter nur unter seiner Adresse fuehrt und
den Nessus danach scannt — traegt auf einer Seite gar keinen Namen und
konsolidiert weiter.

Dieselbe Annahme steckte im manuellen Anlegen: create_asset wies eine bereits
vergebene IP mit 400 ab, der Operator konnte die betroffenen Systeme also nicht
einmal von Hand fuehren. Der Check faellt weg.

Der Fix verhindert neue Merges, loest aber keine bestehende Zeile auf — dort
haelt jede Quelle inzwischen ihren eigenen Pin. tools/find_merged_assets.py
findet die Kandidaten am Status-Flip-Flop im Audit-Log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:34:05 +02:00
vulncheckandClaude Opus 5 8362369ad3 perf(api): die Requests liefen alle auf dem Event-Loop, also nacheinander
Ein Dashboard-Aufruf feuert acht Requests. Im Log kamen alle in derselben
Millisekunde zurueck, /auth/me mit 1116ms — ein Request, der eine Zeile liest.
Nichts davon war fuer sich langsam: einzeln gemessen kosten die acht zusammen
355ms, die Seite brauchte trotzdem so lange wie ihre Summe statt so lange wie
ihr teuerster Request.

Die Handler waren `async def`, obwohl jede Zeile darin eine blockierende
SQLAlchemy-Session benutzt. FastAPI fuehrt `async def` auf dem Event-Loop
selbst aus, also hielt jeder Handler den einzigen Loop fuer die Dauer seines
kompletten Query-Batches fest — und `get_current_user` haengt vor jedem
authentifizierten Request, deshalb war auch der Ein-Zeilen-Request betroffen.
111 solcher Handler, keiner davon mit einem einzigen `await`, sind jetzt
plain `def` und laufen im Worker-Threadpool, wo Blockieren vorgesehen ist.

Damit sie dort auch eine Verbindung finden, deckt der Connection-Pool jetzt
die 40 Threads ab, die FastAPI vergibt, statt 30: die 31. gleichzeitige
Anfrage haette nicht auf eine langsame Query gewartet, sondern auf eine
Connection, und das ist ein Timeout, keine Langsamkeit.

Zweiter Posten auf derselben Seite: kev-recent baute den zusammengefuehrten
KEV-Katalog bei jedem Request neu — 2 MB JSON aus `settings` parsen und
mergen, zweimal pro Seitenaufruf, fuer einen Katalog der sich einmal am Tag
aendert. Jetzt memoisiert, und zwar auf den `_updated_at`-Werten der
Quell-Caches statt auf einer Uhr, damit "jetzt aktualisieren, Seite neu laden"
weiter den neuen Katalog zeigt. Das Datumsparsen laeuft ueber fromisoformat
statt strptime; mit 9100 Aufrufen pro Request war es der Hotspot im Merge.

Gemessen mit acht gleichzeitigen Requests gegen 36.000 Findings auf 300
Assets: Seitenaufruf 420ms -> 190ms, Event-Loop blockiert 250ms -> 50ms,
/auth/me unter Last 304ms -> 91ms. Assets-/Scans-Seite 146ms -> 108ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:53:23 +02:00
vulncheckandClaude Opus 5 65d41fe39e perf(dashboard): erst die Zahlen, dann der Rest — und eine Antwortzeile, die etwas sagt
Gemeldet als "das Dashboard laedt seit den Sync-Status-Sachen langsamer".
Nachmessen ging bisher nicht, und das war der erste Fehler.

Die Antwortzeile im Log lautete "⬅️ Response: 200" — ohne Pfad, ohne Dauer. Das
Dashboard feuert dreizehn Requests gleichzeitig ab, deren Antworten sich also
beliebig verschraenken; welcher davon gebremst hat, war aus dem Log schlicht
nicht ablesbar. Jetzt stehen Methode, Pfad und Dauer in der Zeile, und alles ab
einer Sekunde zusaetzlich als "🐢 SLOW". Das ist das Messinstrument, das der
naechsten Meldung eine Zahl statt einer Vermutung gibt.

Dazu die eine Ursache, die ohne Messung schon feststand: die Seite hing hinter
Promise.all ueber allen dreizehn Requests und zeigte bis zum LANGSAMSTEN nur
"Loading Dashboard...". Die Kennzahlen brauchen davon genau einen. Der hebt
jetzt die Sperre, die Widgets fuellen sich nach.

Wichtig dabei, und der Grund fuer das zusaetzliche widgetsLoading: ein Widget
ohne Daten zeigte bisher "No data.". Wer die Seite frueher sieht, saehe damit
"nichts gefunden", wo "noch nicht da" gemeint ist — die eine Verwechslung, die
dieses Dashboard sich nicht leisten darf. Solange die Charge laeuft, steht dort
ein Spinner. Der Ladebildschirm selbst ist ebenfalls einer, statt einer nackten
Textzeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:10:51 +02:00
vulncheckandClaude Opus 5 7c75774cca fix(assets): der Host-Filter endete bei "M", und die Liste zaehlte pro Zeile einzeln
Zwei Befunde aus demselben Screenshot, beide auf GET /api/v1/assets.

Der Host-Filter auf der Vulnerabilities-Seite holte seine Auswahl aus dem
Listen-Endpoint mit limit=1000 — dessen hartem Maximum. Bei 1194 Assets brach
die Liste damit mitten im Alphabet ab und die restlichen 194 Hosts liessen sich
gar nicht auswaehlen. Ein stilles Limit auf einer Auswahlliste ist die
schlechteste Stelle dafuer: es sieht aus wie "gibt es nicht".

Der Picker braucht zwei Spalten, nicht ganze Assets. Neu ist deshalb
GET /api/v1/assets/names — id und hostname, eine Query, kein Cap, sortiert wie
die Tabelle (case-insensitiv, damit "alpine" nicht hinter "Webserver" faellt).
Vor /{asset_id} deklariert, sonst liest FastAPI "names" als Asset-ID.

Dabei aufgefallen, und der eigentliche Grund fuer die Ladezeit: die Liste hat
pro Zeile ein eigenes SELECT count(*) auf vulnerabilities abgesetzt. Bei
limit=1000 sind das tausend Roundtrips in einem Request — im Backend-Log
dominierte genau dieser Aufruf den Seitenaufbau. Jetzt ein gruppiertes COUNT:
drei Statements fuer 100 Assets, dieselbe Zahl wie fuer 10.

Und die Scan-Jobs-Seite holte dieselben 1000 Assets bei jedem Laden, ohne sie
je zu lesen — der State wurde geschrieben und nirgends verwendet. Ersatzlos
raus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:10:39 +02:00
vulncheckandClaude Opus 5 d7453a1fbb fix(app-scan): eine NVD-Range ohne Obergrenze ist kein Verdikt (CVE-2019-12660)
Gemeldet als False Positive auf jedem IOS-XE-Router, und das war es auch.

NVDs komplette Konfiguration fuer CVE-2019-12660 ist EIN Eintrag: cisco:ios_xe
mit versionStartIncluding 16.1.1 und keiner Obergrenze irgendeiner Art.
Woertlich gelesen: jedes IOS-XE-Release ab 16.1.1, fuer immer. Cisco hat den
Fehler 2019 behoben und schreibt im Advisory ausdruecklich "at the time of
publication"; den 17.15-Zweig gab es erst 2024. Am Finding stand entsprechend
"fixed in: n/a" — und das ist der eigentliche Befund: ein Eintrag ohne
Obergrenze nennt keinen Fix, also kann das Finding sich nie schliessen. Ein
Finding ohne Weg heraus ist kein Verdikt.

Gefixt an der Wurzel statt fuer diese eine CVE: _in_range verwirft einen
cpeMatch mit Unter- und ohne Obergrenze. Das ist exakt die Regel, die
_ranges_from_affected ("only ranges with a real upper bound") und
vmware_entries ("affected, fix unknown" ist kein Host-Verdikt) laengst
anwenden — die NVD-Haelfte war der Ausreisser.

Pro Eintrag, nicht pro CVE: ein Record mit zusaetzlicher beidseitig begrenzter
Range meldet weiter ueber die. Blast Radius nachgemessen auf dem gemeldeten
Geraet (C8000V, 17.15.4c): von neun CVEs an cisco:ios_xe haengt genau diese
eine an einem unbegrenzten Eintrag, die anderen acht an exakten Versionen oder
einer echten Range. Offene Findings muessen nicht von Hand als FP markiert
werden — die Cisco-Reconcile schliesst sie beim naechsten Sync selbst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:10:39 +02:00
vulncheckandClaude Opus 5 55be2354f2 feat(netdisco): UniFi access points and Brocade FC switches, from NVD and cvelistV5 only
Two more firmware lines on the Netdisco path, both matched from the two
authoritative sources alone — no vendor bulletin scraping.

Ubiquiti is the first line where the SNMP os slug decides nothing: the same
fleet answers it as "U6-LR", "U7-Outdoor" and plain "UniFi". The MODEL is what
decides, and it decides twice. Once whether the row is an access point at all —
AP, switch and gateway firmware share one numbering scheme, and CVE-2023-38034
fixes the APs in 6.5.53 and the switches in 6.5.32, so a USW judged against AP
bounds is a finding on the wrong host. And once which records apply, because
half of Ubiquiti's records name a single model: CVE-2024-37380 is the U6+ alone,
fixed in 6.6.74, and every U6-LR on a 6.5.x sits below that bound without ever
having been affected. The model therefore rides in the asset's OS string and
scan_asset_ubiquiti holds a model record to the model it names.

Brocade Fabric OS is covered from NVD alone, and deliberately: Brocade's own
records state the affected versions as an English sentence ("before 9.2.1c3,
and 9.2.2 though 9.2.2b"), which no range collector can read, while NVD's
analysts turn the same prose into proper cpeMatch ranges. Its rebuild letters
are the release and it states real RANGES over them, so the letters are now
ordered rather than dropped — read letter-blind, a switch patched to 9.2.1d sat
below "before 9.2.1c3" and collected the CVE. Brocade's IP switches (ICX,
FastIron) are the same brand with the same version shape and different
firmware; the os slug is what keeps them out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:10:39 +02:00
vulncheckandClaude Opus 5 24b2e8ec4b fix(assets): Netdisco was missing from the source filter, and its switches counted as Manual
The inventory filter had no NETDISCO option, so neither the list nor the CSV
export (which honours the same filter) could be narrowed to the switch estate.
The filter keys on the netdisco_device_ip pin rather than the creation-time
source enum — same rule as vCenter and IGEL — otherwise every switch Wazuh or
Nessus registered first drops out. The MANUAL branch now excludes them too:
without a scanner linkage of its own, a Netdisco switch was answering as
"genuinely manual".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bJSiCMEPYQYdi6emTDRj2
2026-09-09 11:23:50 +02:00
vulncheckandClaude Opus 5 62f4b0b76c fix(netdisco): a device with no name became the asset "192", and pulled the next one onto it
The IP fallback for a nameless device went through `.split(".")[0]` — the
short form of a hostname, which on an address leaves the first octet. That
name was also a match candidate, so the second nameless device in the same /8
found the first one's asset by hostname and was written onto it: one row with
a LANCOM router's vendor and a netgear switch's firmware.

A short form is now only derived from a real device name; a nameless device is
its full canonical address. Assets an earlier sync named after the first octet
of their own pinned IP are renamed on the next sync, so the existing rows heal
without an operator touching them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bJSiCMEPYQYdi6emTDRj2
2026-09-09 11:23:50 +02:00
vulncheckandClaude Opus 5 0bc08a6cc6 feat(netdisco): Extreme Networks EXOS switches, floored ranges
The third firmware line on the Netdisco path, and the one the connector's
stated scope still listed as "needs its own curated matching". An EXOS switch
(an X440G2 on 31.7.2.4, say) was inventoried as an asset and given no CVE
verdict at all.

Extreme is the plain case of the three, and the pass exists mainly for the
reconcile - a switch carries no software inventory, so the app scan's own
reconcile never reaches it. Versions are dotted numbers and the CNA writes
proper ranges, every one of them FLOORED. CVE-2026-8169 states four on one
record: 0 -> <31.7.4, 32.0.0 -> <32.7.4.15, 33.0.0 -> <33.1.100, 33.2.0 ->
<33.7.1, with defaultStatus "unaffected" - so unlike Cisco's records a release
outside all four is one Extreme judged and cleared. _ranges_from_affected reads
that as it stands, which is why there is no Extreme branch in the index build.

The one deliberate difference from the Aruba pass is that Aruba's branch guard
is NOT applied. HPE leaves the floor implicit and writes one range per branch,
so an unfloored "below 10.17.1021" there is a record about 10.17 and nothing
else. Extreme writes the floor itself, including the literal version "0", which
is the vendor saying every release below 31.7.4 whatever train it is on -
guarding that entry by branch would drop exactly the switches it is about.

Both sources, and here the NVD half is the load-bearing one: Extreme's CNA only
started filing in 2025, so every earlier EXOS CVE is a MITRE record whose
affected[] is {vendor: "n/a", product: "n/a", version: "n/a"} and carries no
structured data at all (verified 08.09.2026 on CVE-2023-43121 and
CVE-2024-27453). NVD also files the same firmware under TWO product names and
splits the CVEs between them, so both are queried - verified live the same day:
extremexos answers CVE-2024-27453, CVE-2020-18305 and the 2017 batch; exos
answers CVE-2023-43118..43121 (the Chalet traversal and privilege escalation)
and none of them appear under the other name. The reverse direction is the 2026
batch: CVE-2026-8169 and -8170 were still "Awaiting Analysis" at NVD with zero
CPE entries while Extreme's own records already stated all four ranges.

The product anchor matters for the same reason it did for Cisco: Extreme files
Fabric Engine (VOSS), ExtremeControl, ExtremeCloud IQ - Site Engine, Extreme
Platform ONE, ExtremeGuest Essentials and ExtremeCloud Universal ZTNA under the
one vendor string, and every one of them numbers releases in a scheme that
looks like a switch's ("25.5.12", "9.2", "26.02.11").

Two normalisations, both at the connector: SNMP::Info reads the version out of
sysDescr, so a row can carry the whole sentence, and a patch build spells
itself "22.7.1.1-patch1-11". The tag is dropped because neither source ever
states one as a bound - and Extreme fixes these by moving to the bound's
release, not by patching below it. The rule is aruba_version's, called rather
than copied, because it is genuinely the same rule.

Fabric Engine (VOSS) is deliberately out, and that is an INVENTORY limit rather
than a data one - both sources carry CVE-2025-11192. SNMP::Info has no VOSS
class, so a VSP is classed as Layer3::Passport and answers os "passport",
vendor "avaya" - precisely what an Avaya/Nortel ERS switch answers, whose 5.9.x
firmware is not Fabric Engine. Nothing else on the row separates them, and
"before 9.3" handed to an ERS numbered 5.x matches every one of them. Likewise
ExtremeWare (slug "extremeware"): a different product line whose NVD bounds
stop at 11.1. Both get the asset and no verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:47:10 +02:00
vulncheckandClaude Opus 5 7b69e804ca feat(netdisco): Cisco IOS XE / IOS XR routers, matched exactly
The Netdisco connector shipped with "Cisco, Cumulus and the rest need their
own curated matching" as its stated scope. This is Cisco's half: an IOS XE
router (a C1121X on 17.15.4c, say) was inventoried as an asset and given no
CVE verdict at all.

Cisco is the first product here whose match is EXACT rather than a range, and
that is not caution layered on the data, it is the data. Cisco publishes no
ranges: NVD enumerates 267 separate cpeMatch entries for CVE-2026-20267, each
an exact cpe:2.3:o:cisco:ios_xe:<release> with no versionStart/End anywhere,
and Cisco's own CNA record enumerates the same 268 releases as bare `version`
entries. So "affected" is set membership.

Comparing would be strictly worse, because the numbers are not the release.
Cisco ships 17.15.4, .4a, .4b, .4c and .4d as five separate releases and a
record names some and not others, but _vtuple reads all five as (17,15,4).
Two changes follow, and they are deliberately not the same change:

  * _in_range's exact-version branch now also requires the release suffix to
    be equal. That is a TIGHTENING, applies to every product, and can only
    ever reject - numeric equality alone put all three of the 17.15.1w/x/y
    CVEs on a router running plain 17.15.1.
  * Reading "17.15.4c" as a version at all is NOT shared. _clean_version stays
    dotted-numeric-only and the two Cisco registry entries carry
    "lettered": True instead. That rule has thirteen call sites across six
    services and every one of them compares RANGES, where _vtuple drops the
    letters - letting 1.1.1w through there would hand a letter-blind
    comparison a version whose letter is the release (OpenSSL: 1.1.1a..1.1.1w
    all read as (1,1,1)). Only the exact path can honour a suffix, so only it
    gets one.

Both sources, as everywhere here, and neither is redundant - verified
08.09.2026: the IOS XE batch (CVE-2026-20267..20273) is fully enriched at NVD,
while the IOS XR batch a month later (CVE-2026-20274..20280) is still
"Awaiting Analysis" with zero CPE entries and exists structurally only in
Cisco's own records. The reverse also happens: CVE-2019-12660 states its
affected release in the PRODUCT NAME ("Cisco IOS XE Software 3.2.11aSG",
version "unspecified"), so the CNA half cannot read it and only NVD carries it.

The product anchors matter more for Cisco than for anyone else so far: Cisco
files every product it makes under one vendor and states them as separate
affected[] blocks in the SAME record. CVE-2025-20363 carries five - IOS (2005
versions), IOS XR (13), IOS XE (456), ASA (220) and Firepower (94). Matched
loosely, a router collects an ASA's release list.

No fix version is reported, because neither source states one; a Cisco record
lists what is broken and is silent on what is not. The finding still has its
path to close - a device upgraded to a release the record does not enumerate
stops matching and the reconcile closes it. Likewise, defaultStatus is
"unknown", so an unlisted release is one Cisco did not judge and is read as
"no verdict": a brand-new rebuild is under-reported rather than flagged
forever, which is the direction NVD's own CPE data chooses too.

Classic IOS is deliberately out. It numbers releases "15.2(7)E3", which NVD
backslash-escapes into the CPE and no version scheme here reads; those devices
keep the asset and no verdict.

Also fixes a substring trap the same shape as the "ios" one the Aruba commit
fixed, one letter further along: _os_family tested `"os x" in n`, and "os x"
lives inside "cisco iOS XE" / "cisco iOS XR". Every Cisco router would have
been read as a Mac - compared against macOS ranges by scan_asset_os_apple
(17.15.4c sits below every macOS 26 bound) and stripped of its own NVD
findings by _platform_ok for naming the wrong platform.

Deliberately not included: Cisco's own Security Advisories. They carry the
same CVEs the records already state structurally; the one thing they add is
the fixed release, and scraping sec.cloudapps.cisco.com for it is a third
source with its own failure modes - add it when a missing fix version is what
is actually blocking someone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:19:27 +02:00
vulncheckandClaude Opus 5 bcaa4f02ee feat(netdisco): switch estate as assets, with Aruba firmware CVEs
A switch runs no agent, no MDM enrols it, and a Nessus scan without SNMP
credentials sees an open port and no version — so the devices everything
else is plugged into had no coverage at all. Netdisco already asks them
over SNMP; this reads its deviceinventory report and turns it into assets
and findings.

Aruba detection is family-split on purpose: AOS-CX (10.13.1005), the
ProVision line (16.11.0016) and the Mobility controllers (8.13.1.1) are
three products HPE files under one vendor with overlapping numbers, and a
CX switch matched against a controller's range would be told to install
an image its hardware cannot take. Each family gets its own curated key,
its own anchored product regex and its own NVD CPE, and an index entry
without a floor must additionally share the installed release BRANCH —
HPE writes one range per branch on the same record (CVE-2026-73749
carries 10.18, 10.17, 10.16, 10.13 and 10.10 side by side).

Both CVE sources, as everywhere here: NVD carries usable cpeMatch ranges
for the three 2023 ArubaOS-Switch flaws, while the current AOS-CX batch
(CVE-2026-73749, -44880) sits there unenriched and exists only as HPE's
own CNA records. The switch records state their bounds as prose
("KB/WC/YA/YB/YC.16.11.0015 and below"), which the shared extractor reads
as the number salad (16,11,16,11,12) it looks like — so those are parsed
separately, per branch, and "All versions" is skipped: a finding with no
fix to reach is noise, not a verdict.

The two-letter code line ("WC.", "PL.") names the hardware family, not
the version. It is dropped for comparison and kept in the description,
where it is what an operator matches against HPE's download page.

Also anchors the Apple iOS patterns. They matched "ios" as a substring,
and Netdisco names a Cisco switch's OS exactly that — every Cisco device
in an estate would have been compared against Apple's iPhone ranges,
where IOS 15.2 sits below every bound ever written.

Deliberately not included: HPE's own bulletins (the same data the CNA
records already state structurally) and EOL/EOM (HPE publishes those per
hardware product, not per firmware line, and endoflife.date carries no
ArubaOS at all — an invented date would retire real assets on a guess).
Non-Aruba devices get an asset and no CVE verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:44:19 +02:00
vulncheck ea064107fd feat(m365): a finding never showed the page it was raised from
M365 Apps fixes are not published to NVD and Wazuh's detector never sees
them: the single public record of "CVE-X is fixed in build Y" is the
Microsoft Learn security-updates page this check parses. The finding cited
NVD, CVE.org, Exploit-DB and the EUVD — catalogues that either know nothing
about the M365 fix or, for a fresh CVE, hold no record at all — but not the
page the detection actually came from. The operator could read the claim and
had no way to check it.

Every M365 finding now carries that page under External References, deep-
linked to the monthly section that lists its CVE.

- The anchor is read off the page, never guessed. _parse_security_page
  collects the real h2/h3 ids and keeps the one that matches the date
  heading, trying both the padded and the unpadded day (Microsoft writes
  "December 09, 2025" and "June 9, 2026" and slugifies each verbatim). 94 of
  95 sections currently resolve; a section with no id of its own falls back
  to the plain page URL instead of a fragment that lands nowhere.
- detect_missing_cves reports cve_anchors alongside missing_cves, so the CVE
  points at the section that first published its fix, not at the newest one
  that happens to re-list it.
- Both entry points pass it (nightly run_m365_check and the Intune
  detectedApps path), and the upsert re-attaches on every run, so findings
  raised before this pick the link up on the next pass.

references is a shared column, so the cross-source contract applies to it as
much as to sources: Vulnerability.add_reference() appends and dedupes, the
M365 check replaces only its own earlier link, and the two writers that used
to fill the column only while it was empty (Nessus see_also, the app-CVE
scanner's vendor bulletin) now merge instead — otherwise an M365 reference
on the same row would have cost the host its vendor advisory.

Migration 051 fills in the rows the check will not re-raise (already closed,
or on a host that no longer reports an M365 install) with the plain page URL,
touching only rows m365_check raised and only where nothing is set.

In the UI the link renders as "MS365 Apps Security Updates" rather than a
bare learn.microsoft.com hostname.
2026-09-07 10:36:07 +02:00
vulncheck 83d2757f3f fix(wazuh): a wrong indexer password synced nothing and the run was still COMPLETED
Only the indexer password was changed. The manager API (55000) still
answered, so the agent list came back and no phase raised; the indexer
(9200) refused every query with 401. query_vulnerabilities_from_indexer()
logged that and returned [] — so each of the 31 agents looked like a host
with no CVEs: "deferring backfill", agents_synced += 1, and at the end
"every agent returned 0 CVEs — treating as an outage" as a WARNING in the
container log. Ledger: "sync run #33 wazuh (manual): completed". Green Sync
Health, green "Synchronization Complete", no mail — nothing had been synced.

The earlier fix (51a69a6) only caught the manager-API half: it fails a run
when a phase cannot log in, and it was wired into /scans/autoscan and the
scheduler. run_wazuh_vulnerability_sync — the "Sync Data (Wazuh)" button —
never called it at all, and an indexer error never reached a caller.

Three parts, all needed:

- The client raises. An agent whose CVE list could not be read is not an
  agent with no CVEs, so the indexer query raises WazuhAPIError instead of
  returning []. Every caller already catches per agent — including
  verify_patch_with_rescan, which used to mark a CVE PATCHED because a 401
  gave it an empty list.
- A refused agent is neither synced nor empty: it becomes a FAILED per-asset
  scan row and an entry in errors, and never reaches empty_agents. Otherwise
  one agent answering would let reconcile_empty_agents() close every Wazuh
  finding on the refused host — a mass patch on the strength of a 401.
- The manual sync fails the run (fail_if_source_broken) and answers 502, so
  the dialog goes red instead of "Synchronization Complete", the ledger row
  is failed, Sync Health red, and the alert mail goes out under the usual
  24h cooldown.

reconcile_empty_agents() now also writes run_stats["outage"] when nobody
returned anything; button, autoscan and scheduler all turn that into a
failed run. That is the backstop for an indexer that answers 200 with
nothing — wrong index, cluster still coming up — where nothing raises.

Regression: three tests at the real seam — every agent refused (failed,
agents_synced=0, 502, one mail), one agent refused while another answers
(the refused host keeps its findings), and an indexer answering nothing for
everybody (failed via outage).
2026-09-06 17:33:16 +02:00
vulncheckandClaude Opus 5 bab6433b01 fix(reopen): a week-old Intune list reopened CVEs Defender TVM had just closed
Field report 06.09.2026: two hosts in Intune AND Defender TVM, no Wazuh agent.
Defender saw the Microsoft Edge update and closed a dozen Edge CVEs at 02:10
("device remediated"). At 03:20 the app-CVE scan pulled those hosts' inventory
— Intune detectedApps, which had not caught up and still listed Edge
132.0.6199.32 — and the MSRC fixed-build pass it drives reopened every one of
them. Edge was really at 132.0.6199.62. Next night the same, until Intune
caught up: a change history describing our polling, not the host.

The brake existed. reopen_if_patched already refuses a reopen whose data is
older than the close — but the grace was keyed on the SCANNER, and 'app_scan'
and 'msrc' sat there as "reads a live inventory". That holds on a Wazuh host.
On an Intune host both read the one list already known to lag: they fetch
nothing themselves, they decide from whatever run_app_cve_scan handed them, and
by the time the decision is made the provenance was gone.

Key the grace on the INVENTORY instead. A scan declares whose list it is
deciding from (audit_events.scanning_inventory / use_inventory), and the two
relaying scanners inherit that lag; every other source keeps its own. The
Wazuh-login-refused fall-through, where Intune answered for a Wazuh host, is
covered by the same declaration — it used to count as live.

The numbers were also backwards: Intune stood at 1 day and Defender at 3.
detectedApps depends on the tenant's refresh policy and is routinely a week
behind, while the Defender software list follows device telemetry (the reason
defender_service feeds it to the app scan in the first place). So Intune goes
to 7 days, m365_check with it — same Graph list — and the Defender software
list declares no lag at all; the 3 days under "defender" describe the TVM CVE
feed and stay there.

Deliberately not done: letting Defender TVM override the app scan outright.
"Defender reports nothing" is not "the host is patched" — for software Defender
does not inventory, or a device that stopped reporting, that would bury a
genuine MSRC finding for good, and for Edge MSRC is the only machine-readable
source there is. Past its lag the reopen goes through, so a host that really
regressed still resurfaces. Nothing about how inventories are fetched changed.

Test: tests/test_reopen_stale_inventory.py — the field case, the reopen after
the lag, and a control that a Wazuh host keeps its immediate say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:54:18 +02:00
vulncheckandClaude Opus 5 f29f83653d fix(app-scan): CVEs closed as "no longer detected" from an inventory that was not the host's
Field report 06.09.2026, asset 36: the Wazuh API was dead — the Asset view
could not fetch that host's software inventory at all — and an app-CVE scan
started anyway closed exactly two CVEs on it as "App CVE scan no longer
detects this CVE". Nothing had been patched; the asset was ACTIVE, and the
next scan after Wazuh came back reopened both.

The auto-resolve guard asked only whether `packages` was non-empty, never
where the list came from. The inventory pick is

    if asset.wazuh_agent_id and wazuh: ...  else: intune detectedApps

and that host is registered in both. With the Wazuh login refused, the scan
fell through to Intune's detectedApps — a thinner list with different product
names that does not carry everything syscollector reports. Non-empty, so the
guard passed, and every product Intune does not list read as "no longer
detected". Only that one host, because every other one is Wazuh-only and got
no list at all.

Same shape for browser extensions: they come from the indexer, and a failed
fetch returned [] — "this host has no extensions" — so the reconcile closed
extension findings against a list that cannot contain them.

Track, per asset, which source OWNS the inventory and which one ANSWERED. A
mismatch (or a failed extension fetch) closes and prunes nothing, for the
app-scan reconcile and the MSRC package reconcile alike; detection still runs,
since a product Intune reports really is installed — only retracting needs the
host's own, whole inventory. The Wazuh client now separates a missing IT
Hygiene index (404 → [], as before, for < 4.14) from an unreachable indexer,
which raises.

Test: tests/test_partial_inventory_no_autoclose.py — the field case, the
extension case, and a control that a healthy inventory still closes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:50:43 +02:00
vulncheck ba57975ce0 fix(msrc): a scan started by hand still answered from the previous night's index
Follow-up to 10c4b86. That fix put the MSRC index build before the scans that
read it (01:30 → 03:20 → 03:50), which was the nightly half of the problem.
The GUI half stayed: the operator ran MSRC enrichment AND an app scan by hand
and still saw only Google Chrome on CVE-2026-84324 — no Edge product, no
fixed build.

Two reasons, both invisible from the outside:

  * "MSRC Enrich" (POST /msrc/refresh → msrc_service) pulls per-CVE
    remediation TEXT. It does not touch msrc_product_index_v2 — the
    product→(CVE, FixedBuild) index the Edge findings are created from.
  * The app scan read that index with load_index(), which serves the stored
    blob at ANY age, and rebuilt only when it was missing. So the pass
    decided from whatever the last nightly build left behind, however old,
    and pressing the button again changed nothing.

And there was no button that forced a rebuild: /msrc-scan exists as API only,
with rebuild_index defaulting to False.

Now every scan reads the index through ensure_index(SCAN_INDEX_MAX_AGE = 6h)
instead of load_index — the nightly chain still shares the single 01:30 build,
a run started at 14:00 rebuilds first. A failed rebuild keeps serving the
cache and logs a warning, so an offline MSRC costs no more than before. The
GUI gets "MSRC Scan" next to "MSRC Enrich": rebuild + OS pass as a background
job with status polling, because the CVRF pull outlives the browser's HTTP
patience the same way the app scan did. Readonly may poll the status, not
start it.

Regression: tests/test_msrc_edge_chromium_cve.py section 6 — a 30h-old index
is rebuilt by both run_app_cve_scan and run_msrc_scan, a 1h-old one is not,
and a failed rebuild leaves the run intact with the cached index.
2026-09-06 15:40:42 +02:00
vulncheck 10c4b86c71 fix(msrc): an Edge CVE reached the host two nights after MSRC published it
Field report 06.09.: CVE-2026-84324 / CVE-2026-84354 — Chromium CVEs that
MSRC files under "Microsoft Edge (Chromium-based)", fixed build
152.0.4191.62. The app scan had the Chrome half since 02.09.; the host also
ran Edge 152.0.4191.51, and the morning after MSRC published (03.09. ~17:00)
the finding still showed no MSRC source and no Edge product.

Not a parse or compare defect: replayed against the live 2026-Sep CVRF, the
index carries both CVEs under 'edge' and flags .51 against the .62 fix (now
locked down by the new test). The defect is WHEN the index is built:

    03:20  app-CVE scan   reads msrc_product_index_v2 as stored (allow_stale)
    03:50  MSRC nightly   rebuilds it

The package pass that creates the Edge findings always decided from the
previous night's index, so a CVE MSRC published on day D reached the host on
D+2 — and a user checking on D+1 saw "not detected". b802f3a moved every
other index that scan decides from into the 01:30 refresh job and left this
one where it was.

Now the 01:30 job rebuilds the MSRC index too (a failed build keeps the cache
and logs a warning), the 03:50 job reuses that build via ensure_index(max_age
6h) instead of pulling the 18 documents a second time, the app scan loads the
index once per run instead of parsing the blob once per asset, and an MSRC
pass that dies on one asset is logged instead of vanishing into a stats field
the nightly never prints.

Regression: tests/test_msrc_edge_chromium_cve.py — fixture shaped like the
live document; index build, scan onto the existing Chrome finding, both
reconciles, retraction after the Edge update, job order, ensure_index.
2026-09-06 12:23:11 +02:00
vulncheck 51a69a6e8a fix(scans): a sync whose login phase died was COMPLETED because the indexer half still ran
The Wazuh manager API (55000) refused every login with 500 for a day. The
hourly scheduled scan noticed — the asset sync raised at authentication —
and filed it as "COMPLETED · 1 ERR": the per-agent vulnerability sync reads
the indexer (9200), went through for all 64 agents, and the only failure rule
was "0 agents synced". No red badge, no banner, no mail, until a manual sync
six hours later hit the same 500 and mailed within a second.

Hypothesis that held: two services behind one source. A phase that cannot
log in is a failed run whatever the other phase did.

fail_if_source_broken() replaces the 0-agents rule: a phase failure fails
the run; so does a source-level error among the agents (login refused, 5xx,
connection refused/reset, DNS, SSL) even with 40 agents through. Other
per-agent errors stay completed, but Sync Health now shows them amber as
"N ERR" with the first error, not green OK.

The nightly app CVE scan, SCA refresh and EOL check log in to the same API
per asset and used to warn per asset and finish. Each now logs in once up
front (probe_wazuh_api): refused = a failed Wazuh run in the ledger with the
phase, mailed under the usual 24h cooldown; the API dying mid-run is caught
the same way from the fetch errors. A probe that works writes nothing. The
app scan continues without Wazuh inventory (empty inventory keeps findings,
Intune assets scanned as usual).

The failure mail names phase, counters and the error list, so the reader
sees what still went through. Regression: a scheduler-seam test replays the
incident (asset sync refuses, two agents pass over the indexer, run failed,
agents_synced=2, one mail).
2026-09-06 12:09:58 +02:00
vulncheck 35c7a858c2 fix(rbac): Groups, Policies and Auth Providers were hidden from readonly only in the sidebar
Same gap as Scan Jobs (6a1e6f5), found by walking every GET route as a
readonly user: the sidebar hid the pages, the server did not.

- /groups: GET /api/v1/groups returned every group's member list
  (usernames) to any role, while /auth/users is admin-only. The list
  stays open — Assets and Vulnerabilities need it for their group
  filter — but membership is now only in the admin's response.
  GET /groups/default is admin-only; it is the Groups page's own
  request, so its 403 is what bounces a non-admin back to the
  dashboard.
- /policies: GET /api/v1/policies is editor+ now, matching the sidebar.
  The Assets page fetched it for everyone for its editor-only
  dropdowns; readonly no longer asks (READONLY_DENIED). The page
  bounces on 403.
- /admin/auth: the API was already admin-only, but the page swallowed
  the 403 and rendered an empty shell. It bounces now.
- api.ts: the client-side READONLY_DENIED rejection carries
  response.status 403, so pages that bounce on 403 do not need to know
  the request never left.

Regression test mounts every router as readonly and asserts the exact
allow-list of reachable GET routes, so the next page that is only hidden
in the sidebar fails the suite. README role table updated.
2026-09-03 18:48:22 +02:00
vulncheck 6a1e6f5814 fix(rbac): Scan Jobs was hidden from readonly only in the sidebar
A readonly user saw the dashboard's "coverage at risk" banner and its
"Scan Jobs →" link, and could read the whole page: run ledger, sync
health, error messages, schedules.

Cause: the sidebar commit (e90bf1c) assumed the page "would 403 anyway"
and only hid the menu entry. It never did — every GET under
/api/v1/scans accepted any signed-in role. The banner was added later
on top of that open endpoint and became the visible path in.

Now the server is the gate: list, summary, sync-runs and sync-health
require editor. The banner request is skipped client-side for readonly
(same READONLY_DENIED mechanism as /auth/users), and /scans bounces a
403 back to the dashboard like the audit-log page does. /schedules
stays open because the dashboard shows the next scheduled run to
everyone. README role table updated accordingly.

Regression test mounts the scans router alone with a readonly / editor
user and asserts 403 / not-403 on the four routes.
2026-09-03 18:31:33 +02:00
vulncheck 29649c169b feat(gui): one pagination bar everywhere, with a page you can type into
Three lists paginated three different ways: Assets had « ‹ Page n / N › »,
Vulnerabilities had a row of numbered buttons with an ellipsis, the audit
log a third copy of the Assets bar. None of them let you type a page
number — on 357 pages of assets the only way to page 200 was clicking
Next 181 times or guessing with the per-page selector. The notification
log had no paging at all: the backend already accepted limit/offset, the
GUI fetched the first 50 and stopped.

One shared Pagination component now renders every list bar: range and
total, per-page select, first/prev, a "Page [ ] / N" field that commits
on Enter or blur and clamps to 1..N, next/last. Assets, Vulnerabilities,
Audit Logs and Notification History all use it; the notification-log
endpoint sets X-Total-Count (same pattern as /audit/logs) so that page
shows "x / y" and the header counter shows the real total instead of the
size of the first fetch.
2026-09-03 18:22:49 +02:00
vulncheck 9ca0be24f0 feat(scans): a sync that dies at login is now a failed run, on screen and by mail
A Wazuh API that refused every login for a day was visible only in the
container log. Every scan-jobs row is per asset, and a sync that fails at
authentication never reaches an asset — so /scans showed a clean history of
COMPLETED runs, the manual button said "Sync Failed — check the logs", and
nobody was told.

Ledger: every sync execution (Wazuh, Nessus, Intune, vCenter, IGEL; button
or scheduler) is one sync_runs row with start, end, status, stats and the
error that stopped it, written from its own session so the record never
depends on the job's transaction. A run where every agent failed the same
way counts as failed, not as "0 triggered". Rows left running by a restart
are closed at startup.

Visible: Scan Jobs gets a Sync Health strip (per source: state, last run,
last success, error) and a Sync Runs table; the dashboard shows a banner
while a source is failed or stale (stale follows the schedule interval, a
weekly Nessus is not stale after six days); the Sync Failed modal shows the
real reason, and the sync endpoints return it instead of "check server
logs".

Mail: a failed run mails the sync-alert recipients (own setting, else the
notification defaults) with the error and the last successful sync, i.e.
how long the coverage gap already is — one mail per source per 24h while
it keeps failing, logged as SYNC_FAILURE in the notification log.
2026-09-03 18:11:24 +02:00
vulncheck ece21b9a00 fix(scheduler): the Intune sync scanned against yesterday's catalogue every night
Asked: is every vulnerability-catalogue refresh guaranteed to run BEFORE the
nightly Intune/Defender sync, which on a large estate runs for hours? It was
not. The Intune sync at 02:10 was the first job of the night, and it runs the
app-CVE scan for each of its devices (plus the Defender TVM pull) — reading
the cvelistV5 index and the GitHub-advisory, TeamViewer and IGEL-ISN indexes
as stored. Those were rebuilt inside the app-CVE job at 03:20, i.e. after the
Intune, vCenter and IGEL syncs had already decided from them. So every
Intune-only device was matched against the previous day's catalogue, and only
the 03:20 pass over all assets caught up — hours later on a large estate.

vuln_index_refresh_nightly at 01:30 now rebuilds all four (cvelistV5 with the
MFSA merge, force-fresh ZIP; GitHub advisories; TeamViewer; IGEL ISN) as the
first job of the night, 40 minutes ahead of the Intune sync, and logs its
elapsed time so the gap can be checked. The 03:20 scan reads the same stored
index and no longer walks the 557 MB ZIP a second time. A failed rebuild keeps
the cached index, as before.

tests/test_nightly_index_order.py now asserts the refresh is scheduled before
every sync that reads it, with room for the ZIP walk.
2026-09-03 12:21:20 +02:00
vulncheck 2921278f5b feat(igel): the vendor's own notices, for the flaws that never get a CVE
Many IGEL flaws are published only as an ISN — ISN 2026-09 (UMS information
disclosure, 8.6), ISN 2025-31 (stored XSS in UMS, 8.0), ISN 2025-24 (command
execution in IGEL OS, 8.8) carry no CVE id at all, so neither the CPE path nor
the cvelistV5 path could ever see them. 21 of the 149 ISNs with a stated fix
are like that.

igel_isn_service reads kb.igel.com the way teamviewer_bulletin_service reads
the TeamViewer bulletins: the overview links every notice, each page states
the CVSS at the top and the fixed version per release line under "Update
Instructions" ("OS 12: Update to IGEL OS 12.7.1. OS 11: … 11.10.410"). That
per-line fix is the bound; the ISN id stands in for the CVE where there is
none. App versions on an OS line ("update the Chromium app to 151.x") are not
firmware bounds and are dropped; a UMS line told to move to a newer major
affects the whole old line.

Runs for both the endpoints and the UMS server, in the IGEL sync and in the
nightly app scan, between the CPE pass and the cvelistV5 pass so the shared
touched-set keeps every reconcile honest. The UMS server never had a reconcile
at all; it has one now. The index is rebuilt with the other vendor indexes.

Verified against all 198 live notices (parser and prototype agree on every
page) and by tests/test_igel_isn.py.
2026-09-03 12:19:16 +02:00
vulncheck 6c8cf7dd21 fix(igel): this UMS answers serverstatus blank, so the version can be told
The probe settled it: `/serverstatus` on this UMS returns HTTP 200 with EVERY
field empty — rmGuiServerVersion, activeMQVersion, derbyVersion, serverUUID
and server all "" — anonymously and logged in, on v1, v2 and v3 alike. Not a
renamed key, not a missing login, and the earlier guess that the component
versions were populated was wrong: the log only ever printed key names.

The endpoint is broken, not forbidden. IMI answers an account without object
permissions with an empty body and HTTP 200 rather than a 401, which looks
the same from outside — so the probe now reads the device and firmware tables
on the same session as a control. Full tables next to a blank serverstatus
mean the account is fine and only that one endpoint is dead, which the IGEL
community has reported and PSIGEL worked around by deleting its status call.

So the operator, who knows which UMS they installed, gets to say. New
optional ums_version / ums_build in the IGEL settings, used ONLY when IMI
reported none — a UMS that answers keeps answering for itself, and the Test
button says which of the two it is showing. Without this the one server that
manages the whole thin-client estate stays the only asset with no version to
compare a CVE range against.
2026-09-02 18:47:08 +02:00
vulncheck c93da7787e fix(igel): the UMS kept its own version until it was asked logged in
/serverstatus is the one IMI endpoint that answers without a login, which is
what makes it a reachability probe that can tell "cannot reach the UMS" from
"reached it, credentials rejected". Asked that way, UMS 12 answers 200 with
the whole envelope and its OWN version blank: activeMQVersion and
derbyVersion populated, rmGuiServerVersion "", no buildNumber at all. The
key was never renamed — the value simply is not handed to an anonymous
caller. So the UMS asset, the one server that manages the whole thin-client
estate, landed with no os_version and a description reading "IGEL UMS", and
nothing ever compared it against a CVE range.

Ask once anonymously, and if the version came back blank, log in and ask the
same question again. A login that fails stays silent here on purpose:
test_connection() logs in itself right afterwards and reports a rejected
password as its own step, so raising here would relabel bad credentials as
an unreachable server.

The warning now also names which known version keys were present but empty,
and whether it fired before or after the login. That is the difference
between "UMS renamed the field" and "something did not authenticate" —
the previous log line listed rmGuiServerVersion among the keys while saying
no version was carried, which cost a round trip to the customer to resolve.
2026-09-02 14:56:23 +02:00
vulncheck b802f3a518 fix(scans): three more indexes were a night behind the scan that read them
The MFSA fix (978d4f3) named the mechanism but not its other instances. Same
shape, same result, in the sources nobody looked at that day:

  night N-1  03:20:00  consumer asks, cache expired -> build
             03:20:30  build ends, timestamp stored
  night N    03:20:00  consumer asks -> age 23h59m30s -> served from cache

The stamp is written when the build ENDS, so the next night's call at the same
cron minute is always a build-duration short of the 24h TTL. It clears the
night after (~48h), so each of these refreshed every OTHER night at best, and
which night depended on run-to-run jitter.

Affected, all of them sources that decide findings no other path can see:

  * GitHub repo advisories — Notepad++ and Wazuh. Neither NVD nor cvelistV5
    carries these, so a night stale is a night blind: GHSA-3v57-hgvj-3vj2
    (CVE-2026-49441, critical) is fixed in 4.14.6 while NVD's newest bound for
    the product is 4.14.5.
  * TeamViewer bulletins — the vendor publishes days before NVD has a record,
    and 5 of the 30 bulletins carry no CVE id at all.
  * The Microsoft 365 Apps security page — this one is the MFSA bug with a
    second consumer rather than a later one: the Intune sync (02:10) and the
    M365 job (03:10) share the cache, so whichever asked on the far side of the
    TTL decided the freshness for both. Patch Tuesday builds are the point of
    that job.

The nightly paths now rebuild before they decide; the TTL keeps guarding the
on-demand callers (GUI scan, single asset), which must not pull vendor pages.
A failed rebuild falls back to the cached index — an offline night is no worse
than before. The Intune sync pulls the M365 page itself, because it covers the
Intune-only devices the 03:10 job never sees.

Left alone on purpose: endoflife.date, the MS lifecycle export and the Samsung
SMR page alternate the same way, but carry support-end dates published months
ahead — a day of delay changes no finding.

tests/test_nightly_index_order.py asserts the order and that a vendor site
being down does not take the scan with it.
2026-09-02 14:46:47 +02:00
vulncheckandClaude Opus 5 271043a206 fix(igel): a trailing slash cost the version, and no version costs everything
Four findings from one estate, and three of them are the same shape: the scan
had nothing to compare, so it said nothing, and nothing reads as clean.

  * IMI GET /firmwares/ answers HTTP 404 on UMS 12 ("No endpoint GET
    /umsapi/v3/firmwares/"). The documented URL has no trailing slash. The
    firmware table is the ONLY place a device's version string lives, so the
    slash cost every version in the estate at once.

  * IGEL OS 12 states its base-system build inline — 12.6.0+2 — and
    _clean_version's dotted-numeric rule threw the whole string away. No
    version, no scan: eleven devices on 12.6.0+2 showed zero CVEs while
    sitting inside the range of both current ISNs. Semver excludes build
    metadata from precedence and every IGEL bound is written without it
    (12.7.6, 12.8.3), so the suffix is dropped, not rejected. 12.9.0+3 stays
    clean, which is the correct answer and not a miss — 12.9.0 IS the fix.

  * The UMS server's own version came back empty and its build not at all,
    so the server asset carried no version and Test read "UMS  (build ?)".
    Nothing failed: serverstatus answers 200 either way. The documented IMI v3
    keys are tried first, then the spellings UMS 12 has been seen to use, and
    a payload that carries none of them now logs the keys it did carry — the
    next rename should cost one log line, not an estate.

The fourth is not a bug but the question the CVEs are a footnote to.

IGEL OS 11 stops receiving security fixes on 2026-06-30. No CVE feed will ever
state that, because "unpatchable from here on" is not a CVE, and
endoflife.date carries no IGEL product at all — so the dates are transcribed
from IGEL's own Knowledge Base.

The mapping is the part that needed care, because IGEL's two terms are not the
two this codebase already has, and taken the obvious way round they invert:

  EOL - End of Life        no further ENHANCEMENTS; security fixes still ship.
                           OS 11 hit this in April 2023 when OS 12 launched,
                           and stayed patched for three more years. -> eoasFrom,
                           informational, LOW.
  EOM - End of Maintenance "no updates, no security and bug fixes." -> eolFrom,
                           a real finding.

The 2025-12-31 that circulates for OS 11 is IGEL's original date; the vendor
page now states 30th June, 2026. Third-party migration write-ups still carry
the old one. The vendor's page wins.

OS 12 gets an entry with no EOM, because IGEL has published none and an
invented date would be the only unsourced one on the page. It raises nothing.
UMS 6 (EOM 2023-10-31) does — that one is 1037 days past.

A migrated device has no other way out of its old finding: a thin client has
no software inventory, so the EOL sweep's own reconcile never reaches it. Both
slugs are single-release, and the pass supersedes explicitly when the line it
now runs is one that raises nothing.

Findings say "IGEL product lifecycle", not endoflife.date. A row that named a
source which has never heard of the product sends an operator to a page that
cannot confirm or refute it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:10:03 +02:00