Compare commits
84
Commits
6c8cf7dd21
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd20512710 | ||
|
|
c0ec7177a1 | ||
|
|
063cdd7110 | ||
|
|
d138d8d557 | ||
|
|
e0646077d6 | ||
|
|
518a67a4bb | ||
|
|
081cbb69da | ||
|
|
e7812690da | ||
|
|
dad8f4e0e0 | ||
|
|
d3833e8e39 | ||
|
|
bc6077ebc3 | ||
|
|
38d4faac0b | ||
|
|
ec34e307d3 | ||
|
|
48713bd619 | ||
|
|
f176d6620b | ||
|
|
8fe0cd3caa | ||
|
|
88a6bd47cc | ||
|
|
4f1f750658 | ||
|
|
2be2607972 | ||
|
|
4c2a1fb86a | ||
|
|
6da07b3630 | ||
|
|
e91867dfc2 | ||
|
|
2401196c7e | ||
|
|
9fc9c24a45 | ||
|
|
c23611dae6 | ||
|
|
685b26ca02 | ||
|
|
aa8770e13b | ||
|
|
dd278a27da | ||
|
|
83c524120f | ||
|
|
735a790ee2 | ||
|
|
d708a04481 | ||
|
|
21e34a58ef | ||
|
|
40f08b5fff | ||
|
|
0551af0f92 | ||
|
|
dd82b85a31 | ||
|
|
2214af1d64 | ||
|
|
330a4e21a3 | ||
|
|
a8961ec8fe | ||
|
|
0936cf4ecd | ||
|
|
f3d453d6e9 | ||
|
|
40e9c29920 | ||
|
|
1807ea6086 | ||
|
|
9f57568caf | ||
|
|
a43cbc7634 | ||
|
|
c73d062f5a | ||
|
|
8362369ad3 | ||
|
|
af4863f681 | ||
|
|
91260b9336 | ||
|
|
d18953a2ec | ||
|
|
e8e4707f8e | ||
|
|
174ff72bab | ||
|
|
ff89861392 | ||
|
|
1b2bf47338 | ||
|
|
00855e3de0 | ||
|
|
e465d38f80 | ||
|
|
65d41fe39e | ||
|
|
394a957899 | ||
|
|
7c75774cca | ||
|
|
d7453a1fbb | ||
|
|
55be2354f2 | ||
|
|
0146333704 | ||
|
|
24b2e8ec4b | ||
|
|
62f4b0b76c | ||
|
|
2b4cf2f78f | ||
|
|
0bc08a6cc6 | ||
|
|
a65747d365 | ||
|
|
ec7572ddd0 | ||
|
|
7b69e804ca | ||
|
|
11172958f8 | ||
|
|
ea338d4e98 | ||
|
|
bcaa4f02ee | ||
|
|
ea064107fd | ||
|
|
83d2757f3f | ||
|
|
bab6433b01 | ||
|
|
f29f83653d | ||
|
|
ba57975ce0 | ||
|
|
10c4b86c71 | ||
|
|
51a69a6e8a | ||
|
|
35c7a858c2 | ||
|
|
6a1e6f5814 | ||
|
|
29649c169b | ||
|
|
9ca0be24f0 | ||
|
|
ece21b9a00 | ||
|
|
2921278f5b |
@@ -115,6 +115,14 @@ DASHBOARD_URL=http://localhost:3003
|
||||
# Optional comma-separated fallback models (route=fallback):
|
||||
# OPENROUTER_FALLBACKS=openrouter/auto,anthropic/claude-sonnet-4.6
|
||||
|
||||
# --- Jev triage hint (optional, off by default) ---
|
||||
# Nightly 06:00: asks TypeSafe Jev per open CVE whether it only applies with a
|
||||
# non-default configuration; shown on the Patch Plan. Needs a key: a TypeSafe
|
||||
# key goes straight to api.typesafe.ai, else OPENROUTER_API_KEY above is used.
|
||||
# JEV_TRIAGE_ENABLED=true
|
||||
# TYPESAFE_API_KEY=...
|
||||
# Model pinned per endpoint: jev-1.13.0 (TypeSafe) / typesafe/jev-1.13 (OpenRouter).
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Multi-Provider Authentication (LDAP / OIDC / SAML / TOTP-MFA)
|
||||
|
||||
+2
-1
@@ -186,8 +186,9 @@ SSO/LDAP login. `RoleMapper` re-evaluates role on every login from
|
||||
| `sla_breach_check` | every 1 h | SLA-overdue scan; honors `sla_breach_enabled` toggle + `PolicyStatus.DISABLED` skip; digest or single mode |
|
||||
| `threat_intel_refresh` | every 24 h | Refreshes EPSS, KEV, EUVD across all open vulns; runs `kev_alert_check` straight after, since the catalogs just moved |
|
||||
| `kev_alert_check` | hourly, :25 | Mails actively-exploited CVEs (sources per `kev_alert_sources`, default CISA KEV + ENISA EUVD) that have OPEN findings on active assets; honors `kev_alert_enabled`, idempotent via `kev_alert_state` |
|
||||
| `vuln_index_refresh_nightly` | 01:30 UTC | Rebuilds the cvelistV5 (+MFSA), GitHub-advisory, TeamViewer and IGEL-ISN indexes before the Intune/Defender, vCenter and IGEL syncs read them |
|
||||
| `compliance_sca_nightly` | 02:00 UTC | Wazuh SCA pull for every linked asset |
|
||||
| `intune_sync_nightly` | 02:10 UTC | Intune managed devices → assets + OS-EOL |
|
||||
| `intune_sync_nightly` | 02:10 UTC | Intune managed devices → assets + OS-EOL + per-device app-CVE scan, Defender TVM CVEs when enabled |
|
||||
| `vcenter_sync_nightly` | 02:20 UTC | vCenter + ESXi hosts → assets, EOL, vSphere CVEs (refreshes the build catalog first) |
|
||||
| `igel_sync_nightly` | 02:30 UTC | IGEL UMS server + endpoint devices → assets, IGEL OS CVEs |
|
||||
| `vulnrichment_nightly` | 03:00 UTC | 3-stage CVSS/SSVC/fixed_version cascade |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# TrueVuln — Design-Tokens
|
||||
|
||||
Alle Farben liegen als CSS-Variablen in `frontend/app/globals.css`.
|
||||
Farbwechsel = Token-Werte ändern, nichts anderes. Keine neuen Hex-Werte
|
||||
in Komponenten.
|
||||
|
||||
Format ist **OKLCH** (`L C H`): wahrnehmungsgleichmäßige Helligkeit, dunkle
|
||||
und helle Stufen verlieren die Sättigung nicht. Chroma bleibt für UI unter
|
||||
~0.22, Neutrale unter 0.02.
|
||||
|
||||
## Die Regel
|
||||
|
||||
Drei Gruppen, und nur die Zuordnung entscheidet — nicht der Geschmack:
|
||||
|
||||
| Gruppe | Wofür | Tokens |
|
||||
|---|---|---|
|
||||
| **Neutral** | Der Großteil der UI: Flächen, Text, Border, alles was nur *benennt* | `--tv-fg`, `--tv-fg-soft`, `--tv-fg-muted`, `--tv-line`, `--tv-line-strong`, `--tv-btn-bg`, `--tv-mute-*` |
|
||||
| **Brand** | Genau eine Sache: die primäre Aktion und der aktive Filter | `--tv-brand`, `--tv-brand-solid`, `--tv-brand-hover`, `--tv-brand-soft`, `--tv-brand-ring` |
|
||||
| **Semantisch** | Nur **Zustand** | `--tv-danger*`, `--tv-warn*`, `--tv-ok*`, `--tv-info*` |
|
||||
|
||||
**Ein Farbton bedeutet Zustand, sonst nichts.** Ein Label, das nur eine
|
||||
Identität nennt (Scanner-Name, Paketname, PoC-Anzahl), bleibt neutral —
|
||||
sonst konkurriert es mit dem Rot, das wirklich zählt. Vorher trug jeder
|
||||
Job-Button und jeder Filter eine eigene Farbe; dadurch stach nichts mehr
|
||||
hervor.
|
||||
|
||||
Zustände nie nur über Farbe: jedes Tag trägt zusätzlich seinen Text
|
||||
(`KEV`, `PATCH AVAILABLE`, `TI-TOTAL`), damit es auch bei Farbfehl-
|
||||
sichtigkeit lesbar bleibt.
|
||||
|
||||
## Semantik-Zuordnung (Vulnerabilities)
|
||||
|
||||
| Token | Bedeutung | Beispiele |
|
||||
|---|---|---|
|
||||
| `--tv-danger` | akute Bedrohung / offen | CRITICAL, KEV, RANSOM, TI-TOTAL, EXPLOITABLE, Status `open`, CPR/Priority-Balken hoch |
|
||||
| `--tv-danger-solid` (`.tv-tag--alarm`) | eine Stufe darüber: waffenfähig | MSF, SSVC `widespread` |
|
||||
| `--tv-warn` | erhöht, aber nicht akut | HIGH, EDB, AUTO, EU-CRIT, VPR ≥ 7, SSVC `active` |
|
||||
| `--tv-ok` | erledigt / bestätigt gut | PATCH AVAILABLE, Status `patched`, cross-confirmed |
|
||||
| `--tv-info` | Einordnung ohne Dringlichkeit | MEDIUM, EUVD, EOL, aktiver Suchbegriff |
|
||||
| neutral | reine Identität | Scanner-Namen, Paketnamen, PoC-Zähler, `deferred`, LOW, NON-CVE |
|
||||
|
||||
## Komponenten-Klassen
|
||||
|
||||
`.tv-btn` / `.tv-btn--primary`, `.tv-chip` (+ `.tv-chip__box`), `.tv-tag`
|
||||
(+ `--danger` `--warn` `--ok` `--info` `--alarm` `--lg`), `.tv-bar`
|
||||
(+ `__hi` `__mid` `__low`), `.tv-pop`, `.tv-drawer`.
|
||||
|
||||
Sie sind ausschließlich aus Tokens gebaut und funktionieren dadurch in
|
||||
allen drei Themes (`light` / `mid` / `dark`) ohne Eintrag in der
|
||||
Utility-Remap-Schicht darüber. Neue UI bitte damit bauen, nicht mit
|
||||
`bg-red-100 text-red-700`-Ketten — das ist der Weg, die Remap-Schicht
|
||||
langfristig loszuwerden.
|
||||
|
||||
Der aktive Zustand eines Filter-Chips kommt aus `:has(input:checked)` —
|
||||
kein JS, kein State-Prop.
|
||||
|
||||
## Themes
|
||||
|
||||
`:root` = light. `html[data-theme="mid"|"dark"]` überschreiben nur die
|
||||
Token-Werte: Neutrale hängen sich an die vorhandene Surface-Skala,
|
||||
semantische Flächen werden über `color-mix(… , transparent)` zu
|
||||
Tints (eine Pastellfläche würde auf dunklem Grund ein Loch reißen),
|
||||
und die Vordergrundfarben steigen in der Helligkeit, damit AA bleibt.
|
||||
`--tv-brand-solid` bleibt in beiden Modi dunkel genug für weißen
|
||||
Button-Text.
|
||||
+11
-16
@@ -7,29 +7,24 @@ RUN useradd -m -u 1000 vulnmanager
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies.
|
||||
# - postgresql-client: alembic / psql tooling
|
||||
# - postgresql-client: pg_isready in the entrypoint
|
||||
# - dos2unix: entrypoint line-endings
|
||||
# - libxml2-dev, libxmlsec1-dev, libxmlsec1-openssl, pkg-config:
|
||||
# required by python3-saml (XML signature validation)
|
||||
# - libsasl2-dev, libldap2-dev, libssl-dev:
|
||||
# required by python-ldap if used (ldap3 is pure-python so optional,
|
||||
# kept here for future flexibility)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# No -dev packages: every requirement ships a wheel (xmlsec and lxml bundle
|
||||
# their own libxml2), and the headers only added CVE findings to the image
|
||||
# (linux-libc-dev, libunbound, libxml2). upgrade: the base image lags behind
|
||||
# Debian security updates.
|
||||
RUN apt-get update && apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
postgresql-client \
|
||||
dos2unix \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
pkg-config \
|
||||
libsasl2-dev \
|
||||
libldap2-dev \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
# Copy requirements and install Python dependencies. --only-binary: a
|
||||
# requirement without a wheel fails the build instead of needing a compiler.
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
|
||||
&& pip install --no-cache-dir --only-binary=:all: -r requirements.txt
|
||||
|
||||
# Copy application code (and entrypoint)
|
||||
COPY . .
|
||||
|
||||
+941
-6
File diff suppressed because it is too large
Load Diff
@@ -125,6 +125,7 @@ stay uncorrected until the following night.
|
||||
|
||||
| Time | Job | Stage |
|
||||
|------|-----|-------|
|
||||
| 01:30 | Vulnerability index refresh (cvelistV5 + MFSA, GitHub advisories, TeamViewer, IGEL ISN) — before every sync that decides from them | catalog |
|
||||
| 02:00 | Wazuh SCA compliance refresh | inventory |
|
||||
| 02:10 | Intune inventory sync | inventory |
|
||||
| 02:30 | Network exposure + risk dimensions | inventory |
|
||||
@@ -191,7 +192,9 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
|
||||
|---|:---:|:---:|:---:|
|
||||
| **View** vulnerabilities, assets, reports, dashboards | ✅ | ✅ | ✅ |
|
||||
| **View/download** reports (Executive, Technical CSV, ISO 27001, Patching) | ✅ | ✅ | ✅ |
|
||||
| **View** scan history | ✅ | ✅ | ✅ |
|
||||
| **View** scan history / sync health (Scan Jobs) | ❌ | ✅ | ✅ |
|
||||
| **View** SLA policies (Policies page) | ❌ | ✅ | ✅ |
|
||||
| **View** group membership (Groups page) | ❌ | ❌ | ✅ |
|
||||
| **View** notification history | own only | own only | ✅ all |
|
||||
| **View** AI analysis history | ✅ | ✅ | ✅ |
|
||||
| **Edit** vulnerabilities (status, assign, defer, reopen) | ❌ | ✅ | ✅ |
|
||||
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
# TrueVuln — Update auf `dev` (Netdisco-Connector: Switche & Router als Assets, Aruba-, Cisco- und Extreme-Firmware-CVEs)
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose exec backend alembic current # erwartet: 052 (head)
|
||||
```
|
||||
|
||||
**Migration noetig** (052) — die laeuft aber schon beim Containerstart
|
||||
(`entrypoint.sh`), deshalb oben nur die Kontrolle. Ein zusaetzliches
|
||||
`alembic upgrade head` direkt nach `up -d` rennt gegen den Start-Lauf und
|
||||
bricht mit *"expected to match one row when updating '050' to '051'"* ab; die
|
||||
Migration ist dann trotzdem durch, nur eben im anderen Prozess.
|
||||
|
||||
**Weder der Cisco- noch der Extreme-Teil bringt eine neue Migration mit** —
|
||||
052 bleibt head. Wer die Netdisco-Karte schon getestet hat, muss also nur bauen
|
||||
und neu starten.
|
||||
|
||||
Backend und Frontend.
|
||||
|
||||
---
|
||||
|
||||
## Was neu ist
|
||||
|
||||
Unter **Settings → Netdisco (switches & routers)** gibt es einen Connector wie
|
||||
den fuer vCenter und IGEL. Er liest den `deviceinventory`-Report der Netdisco-API
|
||||
und legt **jedes dort bekannte Geraet** als Asset an — mit Hostname, IP,
|
||||
Standort, Modell, Seriennummer und Firmware-Version. Fuer sechs Firmware-Linien
|
||||
werden daraus CVE-Findings: die drei HPE-Aruba-Linien, Cisco IOS XE / IOS XR und
|
||||
**neu Extreme Networks EXOS (Switch Engine)**.
|
||||
|
||||
Warum ueberhaupt: ein Switch laeuft ohne Agent, keine MDM kennt ihn, und ein
|
||||
Nessus-Scan ohne SNMP-Zugangsdaten sieht einen offenen Port und keine Version.
|
||||
Die Geraete, an denen alles andere haengt, waren damit die einzigen ohne jede
|
||||
Abdeckung — nicht "sauber", nur nie angeschaut. Netdisco fragt sie ohnehin per
|
||||
SNMP ab und kennt die Firmware.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|---|---|
|
||||
| Host / Port | Default `5000` — der Netdisco-eigene Webserver |
|
||||
| HTTPS | aus per Default: der eingebaute Webserver kann **kein** TLS. Anhaken, wenn ein Reverse-Proxy davor steht (dann auch den Port anpassen) |
|
||||
| Verify SSL | nur mit HTTPS relevant — aus, wenn der Proxy ein internes CA-Zertifikat traegt |
|
||||
| API-Key | empfohlen. Wird als `Authorization: <key>` geschickt, ohne `Bearer` |
|
||||
| Username / Passwort | Alternative: wird einmal an `POST /login` geschickt, das genau so einen Key zurueckgibt |
|
||||
|
||||
Key **oder** Zugangsdaten reichen — es ist dasselbe Verfahren, nur von zwei
|
||||
Seiten: der Login *erzeugt* den Key. Beides leer laeuft auch, aber nur fuer eine
|
||||
Instanz ganz ohne Authentifizierung (die oeffentliche Demo). Produktiv gehoert
|
||||
ein Key rein.
|
||||
|
||||
Nightly um **02:40 UTC**, direkt nach dem IGEL-Sync und vor dem App-CVE-Scan um
|
||||
03:20. "Test" und "Sync now" wie bei den anderen Karten; der Lauf landet im
|
||||
Sync-Ledger unter **Scan Jobs**.
|
||||
|
||||
> Der Test meldet *"N devices, M of them on a firmware line with CVE
|
||||
> coverage"* — vorher stand da "of them Aruba firmware". Dieselbe Zahl, nur
|
||||
> zaehlt sie ab sofort auch die Cisco- und Extreme-Geraete mit.
|
||||
|
||||
## Cisco IOS XE / IOS XR — exakt statt verglichen
|
||||
|
||||
Das ist der inhaltlich interessante Teil, und er funktioniert **anders als alles
|
||||
andere im Dashboard**: hier wird nicht verglichen, sondern nachgeschlagen.
|
||||
|
||||
Cisco veroeffentlicht fuer diese Produkte **keine Ranges**. Beide Quellen zaehlen
|
||||
die betroffenen Releases einzeln auf:
|
||||
|
||||
| Quelle | CVE-2026-20267 (IOS XE Hardening, Aug 2026) |
|
||||
|---|---|
|
||||
| NVD | 267 einzelne `cpeMatch`-Eintraege, exakt `cpe:2.3:o:cisco:ios_xe:17.15.4c`, nirgends ein `versionStart`/`versionEnd` |
|
||||
| CVEListv5 | 268 blanke `version`-Eintraege, `defaultStatus: unknown` |
|
||||
|
||||
"Betroffen" ist damit Mengenzugehoerigkeit. Vergleichen waere hier nicht
|
||||
ungenauer, sondern schlicht falsch: Cisco liefert `17.15.4`, `.4a`, `.4b`, `.4c`
|
||||
und `.4d` als **fuenf verschiedene Releases** aus, und ein Record nennt einige
|
||||
davon und andere nicht — als Zahl gelesen sind alle fuenf `(17,15,4)`. Ein
|
||||
Router auf 17.15.4c haette so jede CVE mitbekommen, die nur `17.15.4` nennt.
|
||||
|
||||
Drei Konsequenzen, die beim Testen auffallen werden:
|
||||
|
||||
* **Es steht keine Fix-Version am Finding.** Ein Cisco-Record listet, was kaputt
|
||||
ist, und sagt nichts darueber, was es nicht ist — die "fixed in 17.15.6" steht
|
||||
nur auf Ciscos Advisory-Seite, und die ist bewusst nicht angebunden (siehe
|
||||
unten). Das Finding hat trotzdem seinen Weg zurueck: sobald das Geraet auf ein
|
||||
Release geht, das der Record nicht auffuehrt, trifft nichts mehr und die
|
||||
Reconcile schliesst es.
|
||||
* **Ein nicht gelistetes Release wird nicht gemeldet.** `defaultStatus` ist
|
||||
`unknown`, nicht `unaffected` — was Cisco nicht aufgezaehlt hat, hat Cisco
|
||||
nicht beurteilt, und das lesen wir als "kein Verdikt". Ein brandneuer Rebuild
|
||||
wird also eher zu wenig gemeldet als falsch. NVDs eigene CPE-Daten
|
||||
entscheiden das genauso.
|
||||
* **Gross-/Kleinschreibung und fuehrende Nullen sind egal.** Cisco schreibt
|
||||
denselben Build als `17.09.05a` und `17.9.5a`, und `3.2.0SG` heisst in der CPE
|
||||
`3.2.0sg`. Beide Haelften normalisieren ueber dieselbe Funktion, damit sie
|
||||
nicht auseinanderlaufen.
|
||||
|
||||
**Beide Quellen tragen, und keine ist ueberfluessig.** Stand 08.09.2026: die
|
||||
IOS-XE-Charge (CVE-2026-20267…20273) ist bei NVD vollstaendig angereichert,
|
||||
waehrend die IOS-XR-Charge einen Monat spaeter (CVE-2026-20274…20280) dort noch
|
||||
auf *"Awaiting Analysis"* mit **null** CPE-Eintraegen steht und strukturiert nur
|
||||
in Ciscos eigenen Records existiert. Umgekehrt gibt es auch: CVE-2019-12660
|
||||
schreibt sein betroffenes Release in den **Produktnamen**
|
||||
(`"Cisco IOS XE Software 3.2.11aSG"`, Version `"unspecified"`) — die kennt nur
|
||||
NVD.
|
||||
|
||||
Die Produkt-Anker sind hier wichtiger als irgendwo sonst: Cisco legt **jedes**
|
||||
Produkt unter einem Hersteller ab und schreibt sie als getrennte Bloecke in
|
||||
**denselben** Record. CVE-2025-20363 fuehrt fuenf davon — IOS (2005 Versionen),
|
||||
IOS XR (13), IOS XE (456), ASA (220) und Firepower (94). Lose gematcht bekaeme
|
||||
ein Router die Release-Liste einer Firewall.
|
||||
|
||||
## Extreme Networks EXOS — gefloorte Ranges, und was ich NICHT pruefen konnte
|
||||
|
||||
Extreme ist von den dreien der einfache Fall: Versionen sind blanke Zahlen
|
||||
(`31.7.2.4`), und Extreme schreibt saubere Ranges — **jede mit eigener
|
||||
Untergrenze**. CVE-2026-8169 fuehrt vier davon in einem Record:
|
||||
|
||||
| ab Version | betroffen bis unter |
|
||||
|---|---|
|
||||
| `0` | `31.7.4` |
|
||||
| `32.0.0` | `32.7.4.15` |
|
||||
| `33.0.0` | `33.1.100` |
|
||||
| `33.2.0` | `33.7.1` |
|
||||
|
||||
`defaultStatus` ist hier `unaffected` (anders als bei Cisco) — ein Release
|
||||
ausserhalb aller vier ist also eines, das Extreme geprueft und freigegeben hat.
|
||||
Der Aruba-Branch-Guard laeuft hier bewusst **nicht** mit: die erste Zeile mit der
|
||||
Untergrenze `0` ist Extremes eigene Aussage "alles unter 31.7.4, egal auf welcher
|
||||
Linie" — mit Branch-Guard waeren genau die alten Switche rausgefallen, um die es
|
||||
geht.
|
||||
|
||||
**Beide Quellen, und hier traegt NVD die Hauptlast.** Extremes eigene CNA meldet
|
||||
erst seit 2025. Alles davor — CVE-2023-43119/43120/43121 (Chalet: Directory
|
||||
Traversal und Rechteausweitung), CVE-2024-27453, CVE-2020-18305 — liegt in
|
||||
CVEListv5 nur als MITRE-Platzhalter mit `vendor: "n/a"`, also voellig ohne
|
||||
strukturierte Daten. Umgekehrt standen CVE-2026-8169 und -8170 am 08.09.2026 bei
|
||||
NVD noch auf *"Awaiting Analysis"* mit **null** CPE-Eintraegen, waehrend Extremes
|
||||
Record die vier Ranges oben bereits sauber angab.
|
||||
|
||||
Dazu eine NVD-Eigenheit, die man kennen muss: **dieselbe Firmware liegt dort
|
||||
unter zwei Produktnamen**, und die CVEs sind auf beide verteilt. Beide werden
|
||||
abgefragt (live geprueft am 08.09.2026):
|
||||
|
||||
| CPE | enthaelt |
|
||||
|---|---|
|
||||
| `cpe:2.3:o:extremenetworks:extremexos` | CVE-2024-27453, CVE-2020-18305, die 2017er-Charge |
|
||||
| `cpe:2.3:o:extremenetworks:exos` | CVE-2023-43118…43121 |
|
||||
|
||||
Unter dem jeweils anderen Namen taucht davon **nichts** auf — mit nur einer der
|
||||
beiden CPEs waere die Haelfte unsichtbar geblieben.
|
||||
|
||||
> **Das ist der Teil, den ich nicht selbst verifizieren konnte** — dafuer braucht
|
||||
> es ein echtes Geraet. Laut SNMP::Info-Quellcode (`Layer3::Extreme`) meldet ein
|
||||
> EXOS-Switch Hersteller `extreme` und OS-Slug `xos`, und die Version liest das
|
||||
> Modul per `/Version\s+([^ ]+)/i` aus dem sysDescr. Genau darauf baut die
|
||||
> Zuordnung auf. Falls eure Geraete etwas anderes melden, sagt der Test es sofort:
|
||||
> Asset-OS steht dann nicht als `ExtremeXOS` da, sondern als roher Slug, und der
|
||||
> Zaehler "on a firmware line with CVE coverage" zaehlt sie nicht mit.
|
||||
|
||||
Zwei Dinge werden dabei normalisiert, beide beim Einlesen: der ganze
|
||||
sysDescr-Satz, falls Netdisco ihn mitliefert
|
||||
(`ExtremeXOS (X440G2-24t-10GE4) version 31.7.2.4` → `31.7.2.4`), und der
|
||||
Patch-Anhang eines Patch-Builds (`22.7.1.1-patch1-11` → `22.7.1.1`). Den nennt
|
||||
keine der beiden Quellen je als Grenze — Extreme behebt diese Faelle ueber das
|
||||
Release der Obergrenze, nicht ueber einen Patch darunter.
|
||||
|
||||
## Wie die Aruba-Erkennung entscheidet
|
||||
|
||||
Drei Aruba-Firmware-Linien, die HPE alle unter einem Hersteller ablegt und deren
|
||||
Nummern sich ueberschneiden:
|
||||
|
||||
| Asset-OS | Firmware | Geraete |
|
||||
|---|---|---|
|
||||
| `ArubaOS-CX` | `10.13.1005` | 6000/6300/8300 |
|
||||
| `ArubaOS-Switch` | `16.11.0016` | ProVision: 2530, 2930F, 3810, 5400R |
|
||||
| `ArubaOS` | `8.13.1.1` / `10.7.2.2` | Mobility Controller / Gateways |
|
||||
|
||||
Ein CX-Switch auf 10.13.1005 und ein Controller auf 10.7.2.2 sind beide "10.x"
|
||||
und haben sonst nichts gemeinsam — quer gematcht bekaeme der Switch ein
|
||||
Controller-Image als Empfehlung. Deshalb: jede Familie mit eigenem Schluessel,
|
||||
eigenem verankerten Produkt-Regex und eigener NVD-CPE. Welche Familie ein
|
||||
Netdisco-Geraet ist, muss aus **drei** Angaben zusammenpassen (SNMP-OS-Slug,
|
||||
Hersteller, Form der Versionsnummer) — sonst wird nichts behauptet. Fuer Cisco
|
||||
gilt dieselbe Dreier-Regel.
|
||||
|
||||
Dazu ein **Branch-Guard**: HPE schreibt pro Record eine Range je Release-Zweig
|
||||
(CVE-2026-73749 fuehrt 10.18, 10.17, 10.16, 10.13 und 10.10 nebeneinander), also
|
||||
muss ein Eintrag ohne eigene Untergrenze denselben Zweig (major.minor) haben wie
|
||||
die installierte Version.
|
||||
|
||||
Beide Quellen, wie ueberall hier: NVD hat brauchbare CPE-Ranges fuer die drei
|
||||
ArubaOS-Switch-CVEs von 2023, waehrend die aktuellen AOS-CX-Faelle
|
||||
(CVE-2026-73749, -44880) dort unangereichert liegen und nur als HPEs eigene
|
||||
CNA-Records existieren.
|
||||
|
||||
**Die Buchstaben vor der Version sind egal** — `WC.`, `YA.`, `PL.` benennen die
|
||||
Hardware-Linie, nicht die Version. Verglichen wird `16.11.0016`; die
|
||||
Original-Zeichenkette steht in der Asset-Beschreibung, weil man genau die auf
|
||||
HPEs Download-Seite wiederfindet.
|
||||
|
||||
Bei Cisco ist es **umgekehrt**: die Buchstaben *sind* dort die Version. `17.15.4`
|
||||
und `17.15.4c` sind zwei Releases, und genau deshalb wird bei Cisco exakt
|
||||
verglichen und bei Aruba numerisch.
|
||||
|
||||
## Bewusst nicht drin
|
||||
|
||||
* **Keine Hersteller-Bulletins.** Weder HPEs eigene (hpesbnw*) noch Ciscos
|
||||
Security Advisories. Sie tragen dieselben CVEs, die die CNA-Records bereits
|
||||
strukturiert angeben. Bei Cisco haetten sie *eine* Sache mehr — die
|
||||
Fix-Version —, aber `sec.cloudapps.cisco.com` zu scrapen ist eine dritte
|
||||
Quelle mit eigenen Ausfallarten. Kommt rein, wenn die fehlende Fix-Version
|
||||
jemanden tatsaechlich blockiert.
|
||||
* **Kein EoL/EoM.** Genau das waere fuer einen Switch die wichtigste Meldung —
|
||||
eine Firmware-Linie ohne Sicherheitspatches ist nicht mehr patchbar. Aber HPE
|
||||
veroeffentlicht diese Daten pro **Hardware-Produkt** im Support-Portal, nicht
|
||||
pro Firmware-Linie, und endoflife.date fuehrt ArubaOS ueberhaupt nicht. Ein
|
||||
erfundenes Datum wuerde echte Assets auf Verdacht stilllegen. Sobald es eine
|
||||
Quelle gibt, die EoM je OS-Release sagt, ist der Rest bereits da.
|
||||
* **Extremes Fabric Engine (VOSS) ist nicht dabei — und zwar aus einem
|
||||
Inventar-Grund, nicht aus Datenmangel.** Die CVEs gibt es (CVE-2025-11192 hat
|
||||
sowohl CPE als auch CNA-Range). Aber SNMP::Info hat keine VOSS-Klasse: ein VSP
|
||||
wird als `Layer3::Passport` gefuehrt und meldet OS `passport`, Hersteller
|
||||
`avaya` — exakt dasselbe wie ein Avaya/Nortel-ERS-Switch, dessen 5.9.x-Firmware
|
||||
mit Fabric Engine nichts zu tun hat. Auf der Netdisco-Zeile unterscheidet die
|
||||
beiden nichts, und "vor 9.3" wuerde auf einen ERS mit 5.x losgelassen die
|
||||
komplette ERS-Flotte melden. Sobald sich die Zeilen unterscheiden lassen, sind
|
||||
es ein paar Zeilen Arbeit — sagt Bescheid, wenn ihr VSPs im Bestand habt.
|
||||
* **ExtremeWare** (der alte Slug `extremeware`, Firmware bis 2010) ebenfalls
|
||||
nicht: andere Produktlinie, und NVDs Grenzen dafuer enden bei 11.1.
|
||||
* **Klassisches Cisco IOS (12.x/15.x) ist nicht dabei.** Es nummeriert Releases
|
||||
als `15.2(7)E3`; NVD maskiert die Klammern in der CPE, und kein Versionsschema
|
||||
hier liest das. So ein Geraet bekommt weiterhin Asset und OS-String
|
||||
(`cisco ios`) und **kein** CVE-Verdikt. IOS XE und IOS XR sind abgedeckt.
|
||||
* **Alles Uebrige** (Cumulus, NX-OS, …) bekommt ebenfalls Asset und OS-String
|
||||
ohne CVE-Verdikt. Jede Familie braucht eigenes kuratiertes Matching; ein
|
||||
generischer Versionsvergleich ueber Hersteller hinweg ist genau die Art
|
||||
Scanner, die eine Seite voller falscher Findings produziert.
|
||||
* **"All versions"-Eintraege** in den ArubaOS-Switch-Records werden nicht
|
||||
indiziert. Sie haben keine Obergrenze, lassen sich also weder vergleichen noch
|
||||
durch ein Update innerhalb des Zweigs schliessen — ein Finding ohne
|
||||
erreichbaren Fix ist Rauschen. Auf so einem Zweig wird eher zu wenig als
|
||||
falsch gemeldet.
|
||||
|
||||
## Nebenbefunde, mitgefixt
|
||||
|
||||
Zweimal dieselbe Falle, einmal beim Aruba- und einmal beim Cisco-Update, jeweils
|
||||
einen Buchstaben weiter:
|
||||
|
||||
1. Die Apple-Muster matchten `ios` als **Teilstring**. Netdisco nennt die
|
||||
Firmware eines Cisco-Switches genau so ("ios", als Asset-OS "cisco ios") —
|
||||
jedes Cisco-Geraet im Bestand waere gegen Apples iPhone-Ranges geprueft
|
||||
worden, und IOS 15.2 liegt unter jeder iOS-Grenze, die je geschrieben wurde.
|
||||
2. Und `"os x"` matchte als Teilstring **in `"cisco iOS XE"`** — da steht es
|
||||
drin: `i-os -xe`. Jeder Cisco-Router waere als Mac gelesen worden: gegen
|
||||
macOS-Ranges verglichen (17.15.4c liegt unter jeder macOS-26-Grenze, haette
|
||||
also getroffen) und gleichzeitig um seine eigenen Cisco-Findings gebracht,
|
||||
weil der Plattform-Filter sie als "falsche Plattform" verworfen haette.
|
||||
|
||||
Beide Muster sind jetzt verankert bzw. wortgrenzen-geprueft; `iOS 18.1`,
|
||||
`iPadOS`, `iPhone OS`, `Mac OS X 10.15` und `macOS 15.1` treffen weiterhin.
|
||||
|
||||
## Nachtrag aus dem Test: Geraete ohne Namen, und der fehlende Quellfilter
|
||||
|
||||
Gemeldet mit Screenshot, drei Punkte, davon zwei derselbe Fehler.
|
||||
|
||||
**Assets, die "10" oder "192" heissen.** Nicht jedes Geraet antwortet Netdisco
|
||||
einen Namen — eine AudioCodes-Media-Gateway und ein LANCOM-Router im Testbestand
|
||||
tun es nicht. Der Sync faellt dann auf die kanonische IP zurueck, hat davon aber
|
||||
die **Kurzform** genommen, so wie er es bei einem FQDN tut
|
||||
(`sw-12.example.local` → `sw-12`). Auf einer Adresse angewandt bleibt davon das
|
||||
erste Oktett uebrig: `192`. Ab sofort ist ein namenloses Geraet die **volle
|
||||
IP-Adresse**; eine Kurzform wird nur noch aus einem echten Geraetenamen gebildet.
|
||||
|
||||
**Und deshalb auch die vermischte Zeile.** Der abgeschnittene Name war nicht nur
|
||||
haesslich, er war ein Suchschluessel: das zweite namenlose Geraet im selben /8
|
||||
fand ueber `hostname = "192"` das Asset des ersten und wurde **daraufgeschrieben**.
|
||||
Im Screenshot deshalb ein Asset mit LANCOM als Hersteller und der Firmware
|
||||
`5.4.2.30` eines netgear-Switches — zwei Geraete, eine Zeile. Mit dem vollen
|
||||
Namen kann das nicht mehr passieren, jede Adresse ist eindeutig.
|
||||
|
||||
> Die bereits falsch angelegten Assets muessen **nicht** von Hand aufgeraeumt
|
||||
> werden: heisst ein Asset genau wie das erste Oktett der IP, an die es gepinnt
|
||||
> ist, benennt der naechste Sync es auf die volle Adresse um. Das Geraet, das den
|
||||
> Pin nicht haelt, bekommt dabei sein eigenes Asset. Eine falsche `os_version`
|
||||
> auf dem geheilten Asset bleibt allerdings stehen, bis dessen eigenes Geraet
|
||||
> wieder eine Version liefert — ein leerer Wert ueberschreibt nie einen
|
||||
> vorhandenen. Wem das zu langsam ist: die beiden Zeilen einmal loeschen, der
|
||||
> naechste Sync legt sie sauber neu an.
|
||||
|
||||
**Netdisco fehlte im Quellfilter** ueber der Inventarliste (und damit im
|
||||
CSV-Export, der genau diesen Filter mitnimmt). Ist jetzt drin. Der Filter geht
|
||||
dabei ueber die Pin-Spalte, nicht ueber die Quelle, unter der ein Asset einmal
|
||||
angelegt wurde — sonst faellt jeder Switch raus, den Wazuh oder Nessus zuerst
|
||||
gesehen hat. Nebenbei: dieselben Switche tauchten vorher unter **"Manual"** auf,
|
||||
weil sie keine der abgefragten Scanner-Verknuepfungen hatten. Auch das stimmt
|
||||
jetzt.
|
||||
|
||||
## Vor dem Testen: einmal den CVE-Index bauen lassen
|
||||
|
||||
Der cvelistV5-Index hat eine neue Version (v34, wegen der Extreme-Produkte —
|
||||
vorher v33 fuer Cisco). Der **erste** "Sync now" direkt nach dem Update findet
|
||||
ihn noch nicht und macht dann nur die NVD-Haelfte — im Log steht dann *"no
|
||||
cvelistV5 index yet — CVE pass deferred to the nightly app-CVE scan"*.
|
||||
|
||||
Praktisch heisst das: **IOS-XE-Findings und die aelteren EXOS-Findings kommen
|
||||
sofort** (NVD hat die Daten), **IOS-XR und die EXOS-CVEs von 2026 erst nach dem
|
||||
Indexbau** (NVD hat dort nichts). Entweder eine Nacht warten (01:30 UTC) oder
|
||||
den App-CVE-Scan einmal von Hand anstossen, der baut den Index selbst:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
"http://localhost:8000/api/v1/vulnerabilities/app-cve-scan"
|
||||
```
|
||||
|
||||
## Bitte testen
|
||||
|
||||
1. **Verbindung, drei Varianten.** (a) Demo-Instanz ohne Zugangsdaten
|
||||
(`netdisco-demo-jyxhs.ondigitalocean.app`, Port 443, HTTPS an) → Test meldet
|
||||
`6 devices`. (b) Eigene Instanz mit API-Key ueber HTTP:5000. (c) Falls
|
||||
vorhanden: hinter Reverse-Proxy mit HTTPS. Erwartung jeweils: Geraetezahl und
|
||||
wie viele davon auf einer Firmware-Linie mit CVE-Abdeckung liegen.
|
||||
2. **Falscher Key.** Key verstellen → Test meldet klar "rejected (HTTP 401/403)"
|
||||
bzw. bei einer Login-Seite mit HTTP 200 "no JSON (a login page, most likely)"
|
||||
— nicht "0 Geraete".
|
||||
3. **Erster Sync.** "Sync now" → Assets erscheinen im Inventar, Quelle
|
||||
`NETDISCO`, mit Standort, Modell und Firmware in der Beschreibung.
|
||||
4. **Die vier Aruba-Switche.** Erwartung: der CX auf `PL.10.13.1005` bekommt zwei
|
||||
Findings (CVE-2026-73749 und -44880, jeweils mit dem Fix des **10.13**-Zweigs,
|
||||
nicht dem von 10.17); der CX auf `PL.10.08.1010` bleibt sauber (kein Record
|
||||
fuehrt einen 10.08-Zweig); beide ProVision-Switche (`WC.16.11.0016`,
|
||||
`YA.16.11.0027`) bleiben sauber, weil sie ueber den 16.11-Grenzen liegen.
|
||||
5. **Der Cisco-Router.** Das Testgeraet (C1121X-8PLTEP, `ios-xe`, `17.15.4c`):
|
||||
Asset-OS steht als **`Cisco IOS XE`** da (nicht als `ios-xe`), Version
|
||||
`17.15.4c`. Erwartete Findings: **CVE-2026-20267 bis -20273** (die
|
||||
Hardening-Charge vom August) plus **CVE-2019-12660** und **CVE-2025-20363**.
|
||||
Ohne Fix-Version — das ist so gewollt, siehe oben.
|
||||
6. **Der exakte Match, der eigentliche Punkt.** Wenn moeglich ein zweites
|
||||
IOS-XE-Geraet auf einem *anderen* Rebuild derselben Zeile (z. B. `17.15.4`
|
||||
oder `17.15.4a`) gegenpruefen: die Finding-Listen der beiden Geraete duerfen
|
||||
sich **unterscheiden**. Wenn beide identisch sind, obwohl die Releases
|
||||
verschieden sind, stimmt etwas nicht.
|
||||
7. **Firmware hochziehen.** Einen betroffenen Switch oder Router aktualisieren
|
||||
(Cisco: auf ein Release, das in den Records nicht auftaucht, etwa 17.15.6),
|
||||
Sync → das Finding schliesst sich von selbst (Change History: "at/past the
|
||||
fix"). Das ist der Punkt, an dem sich zeigt, ob die Erkennung einen Weg
|
||||
zurueck hat.
|
||||
8. **Klassisches IOS und der Rest.** Ein Geraet mit OS-Slug `ios` (12.x/15.x):
|
||||
Asset ja, OS `cisco ios`, aber **keine** Findings — und vor allem keine
|
||||
Apple- oder macOS-CVEs. Dasselbe fuer Cumulus & Co.
|
||||
9. **Keine macOS-CVEs auf Netzwerkgeraeten.** Explizit gegenpruefen: auf keinem
|
||||
`Cisco IOS XE`- oder `Cisco IOS XR`-Asset darf eine macOS- oder iOS-CVE
|
||||
stehen. Das ist der Nebenbefund von oben.
|
||||
10. **Die Extreme-Switche — und zuerst: kommt die Zeile ueberhaupt richtig an?**
|
||||
Nach dem Sync ein EXOS-Geraet im Inventar aufmachen. Erwartung: Asset-OS
|
||||
steht als **`ExtremeXOS`** da (nicht als `xos` oder `extreme xos`) und die
|
||||
Version als blanke Zahl (`31.7.2.4`; bei einem Patch-Build `22.7.1.1` statt
|
||||
`22.7.1.1-patch1-11`). Steht dort etwas anderes, meldet euer Netdisco andere
|
||||
Slugs als angenommen — dann bitte die Zeile aus dem `deviceinventory`-Report
|
||||
schicken (`vendor`, `os`, `version`), das ist dann ein Zweizeiler.
|
||||
11. **Die EXOS-Findings.** Ein Switch unterhalb der Grenzen (alles unter 31.7.4,
|
||||
oder 32.x unter 32.7.4.15) bekommt **CVE-2026-8169** und **CVE-2026-8170**,
|
||||
beide **mit** Fix-Version — anders als bei Cisco, hier gibt Extreme sie an.
|
||||
Aeltere Stände zusaetzlich aus der NVD-Haelfte: ein Switch auf 22.6 etwa
|
||||
CVE-2023-43119/43120/43121, CVE-2024-27453 und CVE-2020-18305. Ein Switch
|
||||
auf 31.7.4 oder neuer bleibt bei den 2026ern sauber.
|
||||
12. **Ein VSP mit VOSS / ein Avaya-ERS**, falls vorhanden: Asset ja, OS
|
||||
`avaya passport`, **keine** Findings. Das ist Absicht (siehe "Bewusst nicht
|
||||
drin") — wichtig ist vor allem, dass dort *nichts Falsches* auftaucht.
|
||||
13. **Umbenennen.** Einen Switch in Netdisco umbenennen, Sync → dasselbe Asset
|
||||
uebernimmt den neuen Namen, es entsteht **kein** zweites.
|
||||
14. **Entfernen.** Ein Geraet aus Netdisco loeschen, Sync → Asset wird `inactive`
|
||||
(mit Audit-Eintrag), nicht geloescht. Ein nur ausgeschalteter oder gerade
|
||||
nicht erreichbarer Switch bleibt `active`, solange Netdisco ihn noch fuehrt.
|
||||
15. **Ledger.** Scan Jobs → der Lauf steht als `netdisco` drin, mit Statistik und
|
||||
im Fehlerfall als `failed` samt Mail. Hinweis: Laeufe von **vor** diesem
|
||||
Update zeigen in der Historie `0` bei "with firmware CVE coverage" — der
|
||||
Zaehler wurde umbenannt, alte Eintraege haben den alten Schluessel. Ab dem
|
||||
naechsten Sync stimmt die Zahl wieder.
|
||||
16. **Ohne Netdisco pruefbar** (Regeln offline):
|
||||
```bash
|
||||
venv/bin/python tests/test_aruba_firmware.py
|
||||
venv/bin/python tests/test_cisco_firmware.py
|
||||
venv/bin/python tests/test_extreme_firmware.py
|
||||
venv/bin/python tests/test_netdisco_inventory.py
|
||||
venv/bin/python tests/test_netdisco_nameless_device.py
|
||||
```
|
||||
17. **Ein Geraet ohne Namen.** Falls im Bestand: Asset heisst jetzt nach der
|
||||
**vollen** IP (`192.168.1.6`), nicht `192`. Zwei namenlose Geraete = zwei
|
||||
Assets, jedes mit seinem eigenen Hersteller und seiner eigenen Firmware.
|
||||
Die alten `10`/`192`-Zeilen aus dem letzten Test: einmal syncen, dann
|
||||
schauen, ob sie den vollen Namen tragen.
|
||||
18. **Quellfilter und Export.** Inventar → Filter auf **Netdisco**: es kommen
|
||||
genau die Netdisco-Geraete, auch die, die Wazuh oder Nessus zuerst
|
||||
registriert hat. "Export CSV" bei gesetztem Filter enthaelt dieselbe Menge.
|
||||
Und: unter **Manual** darf jetzt kein Switch mehr stehen.
|
||||
@@ -0,0 +1,26 @@
|
||||
Moin,
|
||||
|
||||
danke fuer den Screenshot — drei Punkte, davon zwei derselbe Fehler. Alles gefixt, liegt auf dev. Keine neue Migration, 052 bleibt head, also nur bauen und neu starten (Backend + Frontend).
|
||||
|
||||
git pull && docker compose build && docker compose up -d
|
||||
|
||||
*1+2: die Assets, die "10" und "192" heissen — und die vermischte Zeile*
|
||||
Nicht jedes Geraet antwortet Netdisco einen Namen (bei euch die AudioCodes-Gateway und der LANCOM). Der Sync faellt dann auf die kanonische IP zurueck, hat davon aber die Kurzform genommen — so wie er es bei einem FQDN macht (sw-12.example.local → sw-12). Auf einer Adresse angewandt bleibt das erste Oktett uebrig. Ab sofort ist ein namenloses Geraet die *volle IP*, eine Kurzform wird nur noch aus einem echten Namen gebildet.
|
||||
|
||||
Und das ist auch die Erklaerung fuer die Zeile mit LANCOM als Hersteller und der netgear-Firmware 5.4.2.30: der abgeschnittene Name war nicht nur haesslich, er war ein *Suchschluessel*. Das zweite namenlose Geraet im selben /8 hat ueber hostname="192" das Asset des ersten gefunden und wurde daraufgeschrieben. Zwei Geraete, eine Zeile. Mit der vollen Adresse kann das nicht mehr passieren.
|
||||
|
||||
Aufraeumen musst du nichts: heisst ein Asset genau wie das erste Oktett der IP, an die es gepinnt ist, benennt der naechste Sync es um, und das Geraet, das den Pin nicht haelt, bekommt sein eigenes Asset. Einzige Einschraenkung: die falsche os_version bleibt auf dem geheilten Asset stehen, bis dessen eigenes Geraet wieder eine Version meldet — ein leerer Wert ueberschreibt nie einen vorhandenen. Wenn dir das zu langsam ist, die zwei Zeilen einmal loeschen, der naechste Sync legt sie sauber neu an.
|
||||
|
||||
*3: Netdisco im Quellfilter*
|
||||
War schlicht nicht in der Liste, und damit fehlte er auch im CSV-Export, der genau diesen Filter mitnimmt. Ist drin. Der Filter geht ueber die Netdisco-Verknuepfung und nicht ueber die Quelle, unter der ein Asset mal angelegt wurde — sonst faellt jeder Switch raus, den Wazuh oder Nessus zuerst gesehen hat. Nebenbei aufgefallen: dieselben Switche standen vorher unter "Manual", weil sie keine der abgefragten Scanner-Verknuepfungen hatten. Auch das stimmt jetzt.
|
||||
|
||||
*Was ich dich zu pruefen bitte*
|
||||
1. Einmal syncen, dann die beiden Zeilen anschauen: voller Name statt "10"/"192", und jedes Geraet mit seinem eigenen Hersteller und seiner eigenen Firmware.
|
||||
2. Inventar → Filter auf Netdisco: kommen alle Netzwerkgeraete, auch die, die ein anderer Scanner zuerst kannte? "Export CSV" bei gesetztem Filter → dieselbe Menge.
|
||||
3. Unter "Manual" darf jetzt kein Switch mehr stehen.
|
||||
4. Aruba/Cisco/Extreme unveraendert gegentesten, dass nichts kaputtgegangen ist.
|
||||
5. Offline, ganz ohne Netdisco:
|
||||
venv/bin/python tests/test_netdisco_nameless_device.py
|
||||
venv/bin/python tests/test_netdisco_inventory.py
|
||||
|
||||
Die OS-Spalte zeigt bei den beiden weiterhin "AudioCodes LTD" bzw. "LANCOM Systems" — das ist der Hersteller, weil Netdisco fuer die Geraete gar keinen OS-Slug meldet. Kosmetisch, auf Findings hat es keinen Einfluss; sag Bescheid, wenn es stoeren soll.
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Sync run ledger + SYNC_FAILURE notification type
|
||||
|
||||
Revision ID: 050
|
||||
Revises: 049
|
||||
Create Date: 2026-09-03 14:00:00.000000
|
||||
|
||||
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 while nothing had been synced. This table records every sync
|
||||
execution (any source, button or scheduler) with its outcome, and the new
|
||||
notification type carries the failure mail in the notification log.
|
||||
|
||||
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
|
||||
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "050"
|
||||
down_revision = "049"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sync_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("source", sa.String(length=30), nullable=False),
|
||||
sa.Column("trigger", sa.String(length=20), nullable=False, server_default="manual"),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="running"),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("stats", sa.Text(), nullable=True),
|
||||
sa.Column("alerted_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_sync_runs_source", "sync_runs", ["source"])
|
||||
op.create_index("ix_sync_runs_status", "sync_runs", ["status"])
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute("ALTER TYPE notificationtype ADD VALUE IF NOT EXISTS 'SYNC_FAILURE'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_sync_runs_status", table_name="sync_runs")
|
||||
op.drop_index("ix_sync_runs_source", table_name="sync_runs")
|
||||
op.drop_table("sync_runs")
|
||||
# Postgres cannot drop an enum label — see 043; deliberately left in place.
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Attach the M365 Apps security-updates page to existing M365 findings
|
||||
|
||||
Revision ID: 051
|
||||
Revises: 050
|
||||
Create Date: 2026-09-07 10:00:00.000000
|
||||
|
||||
M365 Apps fixes are not in NVD and Wazuh never sees them: the only public
|
||||
record of "CVE-X is fixed in build Y" is the Microsoft Learn security-updates
|
||||
page the M365 check parses. Findings now carry that page as an external
|
||||
reference — the evidence they were raised from.
|
||||
|
||||
The check attaches it on its next run, but only to findings it re-raises: a
|
||||
row already closed, or one on a host that no longer reports an M365 install,
|
||||
would never get it. This fills those in once, with the plain page URL (the
|
||||
monthly section a closed finding came from is not recoverable here — the
|
||||
check deep-links the ones it re-raises).
|
||||
|
||||
Only rows the M365 check itself raised are touched, and only when they carry
|
||||
no reference yet, so nothing another source wrote is overwritten.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "051"
|
||||
down_revision = "050"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_URL = ("https://learn.microsoft.com/en-us/officeupdates/"
|
||||
"microsoft365-apps-security-updates")
|
||||
# "references" is a reserved word — it stays quoted in raw SQL.
|
||||
_JSON = '[\"' + _URL + '\"]'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
'UPDATE vulnerabilities SET "references" = \'' + _JSON + '\' '
|
||||
"WHERE first_detected_by = 'm365_check' "
|
||||
'AND ("references" IS NULL OR "references" = \'\' '
|
||||
'OR "references" = \'[]\')'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
'UPDATE vulnerabilities SET "references" = NULL '
|
||||
"WHERE first_detected_by = 'm365_check' "
|
||||
'AND "references" = \'' + _JSON + '\''
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Add NETDISCO asset source + netdisco_device_ip
|
||||
|
||||
Revision ID: 052
|
||||
Revises: 051
|
||||
Create Date: 2026-09-08 09:00:00.000000
|
||||
|
||||
Netdisco as a sixth inventory source next to Wazuh, Nessus, Intune, vCenter
|
||||
and IGEL. It registers every device Netdisco has discovered — the switch
|
||||
estate, which carried no CVE detection at all until now: switch firmware runs
|
||||
no Wazuh agent, no MDM enrols it, and a Nessus scan without SNMP credentials
|
||||
sees an open port and no version.
|
||||
|
||||
`netdisco_device_ip` pins the asset to Netdisco's canonical device address —
|
||||
the identifier Netdisco itself keys on, statically configured on a switch's
|
||||
management interface and stable across the renames switches do get.
|
||||
|
||||
The firmware version needs no new column: it IS the OS version, so it lands in
|
||||
`os_version` like every other OS. What lands there is the NUMERIC form
|
||||
(16.11.0016); HPE's two-letter code-line prefix ("WC.") names the hardware
|
||||
family, not the version, and is kept in the asset description where an
|
||||
operator matching HPE's download page can still read it.
|
||||
|
||||
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
|
||||
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "052"
|
||||
down_revision = "051"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute("ALTER TYPE assetsource ADD VALUE IF NOT EXISTS 'NETDISCO'")
|
||||
op.execute("""
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS netdisco_device_ip VARCHAR(45);
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_netdisco_device_ip "
|
||||
"ON assets (netdisco_device_ip);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_assets_netdisco_device_ip;")
|
||||
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS netdisco_device_ip;")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Add netdisco_serial — the Netdisco pin that survives a new address
|
||||
|
||||
Revision ID: 053
|
||||
Revises: 052
|
||||
Create Date: 2026-09-10 12:00:00.000000
|
||||
|
||||
Field report 2026-09-10: a UniFi AP updated its firmware, took a new DHCP
|
||||
lease, and became a SECOND asset — same hostname, same serial, the old row
|
||||
left INACTIVE by the reconcile because no device pointed at it any more.
|
||||
|
||||
`netdisco_device_ip` was the only Netdisco pin, and the hostname fallback is
|
||||
pinned on it too, so the asset holding the OLD address was filtered out of the
|
||||
lookup meant to catch exactly this. The serial number is the identity the
|
||||
address only stands in for, and Netdisco already reports it per device — it
|
||||
was just being written into the description text.
|
||||
|
||||
No backfill: the first sync after this migration matches every device on its
|
||||
(unchanged, at that moment) address and stamps the serial then. Assets forked
|
||||
before this are not merged — that is a manual decision, findings and audit
|
||||
trail hang off both rows.
|
||||
|
||||
Idempotent (IF NOT EXISTS), same shape as 048.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "053"
|
||||
down_revision = "052"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS netdisco_serial VARCHAR(64);
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_netdisco_serial ON assets (netdisco_serial);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_assets_netdisco_serial;")
|
||||
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS netdisco_serial;")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add intune_serial — what tells a re-enrolment from a namesake
|
||||
|
||||
Revision ID: 054
|
||||
Revises: 053
|
||||
Create Date: 2026-09-11 12:00:00.000000
|
||||
|
||||
The Intune sync collapsed every managedDevice sharing a device name into one
|
||||
record before touching an asset. That is right for the case it was written for
|
||||
— wipe a device, enrol it again, and Intune keeps BOTH entries under the same
|
||||
name — but it was keyed on the name alone. Two DIFFERENT devices with the same
|
||||
name (a Windows default DESKTOP-XXXXXXX, a userless Android out of the same
|
||||
batch) were collapsed too, and the one that lost dropped out of the sync
|
||||
entirely: no asset, no inventory, no findings. A system missing from the
|
||||
inventory is a coverage hole nobody sees.
|
||||
|
||||
The hardware serial separates the two cases and Graph already sends it in the
|
||||
same payload (`serialNumber`, part of the $select). Re-enrolment keeps it; two
|
||||
devices do not share it.
|
||||
|
||||
No backfill: the next sync stamps the serial on every device it matches by id
|
||||
or name, exactly as 053 did for Netdisco. Assets already merged by name are
|
||||
not split — findings and audit trail hang off the surviving row, so that stays
|
||||
a manual decision.
|
||||
|
||||
Idempotent (IF NOT EXISTS), same shape as 053.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "054"
|
||||
down_revision = "053"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS intune_serial VARCHAR(64);
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_intune_serial ON assets (intune_serial);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_assets_intune_serial;")
|
||||
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS intune_serial;")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add igel_serial — the IGEL device's hardware serial
|
||||
|
||||
Revision ID: 055
|
||||
Revises: 054
|
||||
Create Date: 2026-09-14 12:00:00.000000
|
||||
|
||||
The IGEL sync pinned a thin client on `unitID` alone, and IMI's `unitID` is the
|
||||
MAC (`mac` carries the same value). `deviceSerialNumber` came in the same
|
||||
`?facets=details` payload and was never read, so no IGEL asset carried a
|
||||
serial and a changed unit ID forked the asset.
|
||||
|
||||
No backfill: the next sync stamps the serial on every device it matches by
|
||||
unit ID. Idempotent (IF NOT EXISTS), same shape as 053/054.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "055"
|
||||
down_revision = "054"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS igel_serial VARCHAR(64);
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_igel_serial ON assets (igel_serial);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_assets_igel_serial;")
|
||||
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS igel_serial;")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add vmware_serial — the ESXi host's hardware serial
|
||||
|
||||
Revision ID: 056
|
||||
Revises: 055
|
||||
Create Date: 2026-09-14 15:00:00.000000
|
||||
|
||||
vCenter reports a host's chassis serial in `hardware.systemInfo` (the
|
||||
`serialNumber` field, or the SMBIOS serial / service tag), and the sync never
|
||||
read it. Display only: the hardware UUID stays the match key.
|
||||
|
||||
No backfill: the next vCenter sync stamps it. Idempotent (IF NOT EXISTS), same
|
||||
shape as 053–055.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "056"
|
||||
down_revision = "055"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS vmware_serial VARCHAR(64);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS vmware_serial;")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Repair Defender's OS placeholder on application CVEs
|
||||
|
||||
Revision ID: 057
|
||||
Revises: 056
|
||||
Create Date: 2026-09-15 12:00:00.000000
|
||||
|
||||
Until d708a04 Defender labelled a CVE with the asset's OS and build whenever
|
||||
its software export had not caught up yet, so Chrome and Edge CVEs read
|
||||
"Microsoft Windows" / 10.0.26100.x (CVE-2026-87474). The sync now replaces
|
||||
that on rows Defender still reports; this repairs the rest, meaning rows
|
||||
Defender dropped, rows MSRC took over, and closed rows.
|
||||
|
||||
Only rows Defender created whose package is still the asset's OS string:
|
||||
1. A package row another scanner recorded names the real product → take the
|
||||
earliest one's name and version.
|
||||
2. Otherwise, if the fix is a dotted version whose major differs from the
|
||||
placeholder build (153.x against 10.0.x), the placeholder cannot be this
|
||||
CVE's product. Clear name and version: unknown rather than wrong.
|
||||
A Windows CVE fixed in 10.0.x or an iOS CVE at 18.x keeps its label.
|
||||
|
||||
Data only, not reversible. The downgrade is a no-op.
|
||||
"""
|
||||
import re
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
revision = "057"
|
||||
down_revision = "056"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_DOTTED = re.compile(r"^\d+(\.\d+)+$")
|
||||
|
||||
|
||||
def _major(v):
|
||||
return v.split(".")[0] if v and _DOTTED.match(v) else None
|
||||
|
||||
|
||||
def repair(conn) -> int:
|
||||
rows = conn.execute(text("""
|
||||
SELECT v.id, v.package_version, v.fixed_version
|
||||
FROM vulnerabilities v JOIN assets a ON a.id = v.asset_id
|
||||
WHERE v.first_detected_by = 'defender'
|
||||
AND a.operating_system IS NOT NULL AND a.operating_system <> ''
|
||||
AND v.package_name = a.operating_system
|
||||
""")).fetchall()
|
||||
fixed = 0
|
||||
for vid, pkg_ver, fix_ver in rows:
|
||||
child = conn.execute(text("""
|
||||
SELECT package_name, package_version FROM vulnerability_packages
|
||||
WHERE vulnerability_id = :id ORDER BY first_detected_at, id LIMIT 1
|
||||
"""), {"id": vid}).fetchone()
|
||||
if child:
|
||||
conn.execute(text("UPDATE vulnerabilities SET package_name = :n, "
|
||||
"package_version = :ver WHERE id = :id"),
|
||||
{"n": child[0], "ver": child[1], "id": vid})
|
||||
elif _major(fix_ver) and _major(pkg_ver) and _major(fix_ver) != _major(pkg_ver):
|
||||
conn.execute(text("UPDATE vulnerabilities SET package_name = NULL, "
|
||||
"package_version = NULL WHERE id = :id"), {"id": vid})
|
||||
else:
|
||||
continue
|
||||
fixed += 1
|
||||
return fixed
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
repair(op.get_bind())
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Add vulnerabilities.vendor_severity — the vendors' own ratings
|
||||
|
||||
Revision ID: 058
|
||||
Revises: 057
|
||||
Create Date: 2026-09-15 10:00:00.000000
|
||||
|
||||
JSON {"Microsoft": "Critical", "TeamViewer": "Important", ...}. Vendors rate
|
||||
by their own scale, which can differ from the CVSS band the severity column
|
||||
follows: CVE-2026-85921 is CVSS 8.2 (High), Critical at MSRC. The detail view
|
||||
shows a rating only where it disagrees.
|
||||
|
||||
No backfill: the next MSRC ingest (nightly 2 months, Sunday 18 months), app
|
||||
scan and mobile check stamp it. Idempotent (IF NOT EXISTS), same shape as 053–056.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "058"
|
||||
down_revision = "057"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS vendor_severity TEXT;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS vendor_severity;")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add CONTAINER asset source + vulnerabilities.baseline
|
||||
|
||||
Revision ID: 059
|
||||
Revises: 058
|
||||
Create Date: 2026-09-17 09:00:00.000000
|
||||
|
||||
Container images scanned by Trivy or Grype on a Docker host and pushed to
|
||||
POST /api/v1/vulnerabilities/sync/container. One asset per image (repo:tag),
|
||||
see container_scan_service. The image name is the identity, so no pin column.
|
||||
|
||||
`vulnerabilities.baseline` marks findings from a repository's first report,
|
||||
which send no new-finding mail. Its own column, not notification_suppressed:
|
||||
that is the operator's mute and would also silence SLA mails for good.
|
||||
|
||||
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
|
||||
Postgres → autocommit_block. Idempotent (IF NOT EXISTS). Postgres cannot drop
|
||||
an enum value, so downgrade only drops the column.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "059"
|
||||
down_revision = "058"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute("ALTER TYPE assetsource ADD VALUE IF NOT EXISTS 'CONTAINER'")
|
||||
op.execute("ALTER TABLE vulnerabilities "
|
||||
"ADD COLUMN IF NOT EXISTS baseline BOOLEAN NOT NULL DEFAULT false;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS baseline;")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Container image → Docker host asset
|
||||
|
||||
Revision ID: 060
|
||||
Revises: 059
|
||||
Create Date: 2026-09-18 12:00:00.000000
|
||||
|
||||
The Dockhand sync keeps one CONTAINER asset per image; which host runs it was
|
||||
only text in the description. This table links an image to the host asset
|
||||
(the Wazuh / Nessus / vCenter record of the Docker host) whose hostname the
|
||||
Docker host reports, so the host shows its images and the image its host.
|
||||
Rewritten by every Dockhand sync; both sides cascade on asset delete.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "060"
|
||||
down_revision = "059"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"container_placements",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("image_asset_id", sa.Integer(),
|
||||
sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("host_asset_id", sa.Integer(),
|
||||
sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("containers", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_container_placements_image_asset_id", "container_placements",
|
||||
["image_asset_id"])
|
||||
op.create_index("ix_container_placements_host_asset_id", "container_placements",
|
||||
["host_asset_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("container_placements")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add vulnerabilities.jev_config_dependent + jev_checked_at
|
||||
|
||||
Revision ID: 061
|
||||
Revises: 060
|
||||
Create Date: 2026-09-19 10:00:00.000000
|
||||
|
||||
Jev's answer to "only exploitable with a non-default configuration?", per CVE
|
||||
text. An operator hint on the patch plan; it never closes a finding and never
|
||||
enters priority_score. No backfill: the nightly Jev job fills it when enabled.
|
||||
Idempotent (IF NOT EXISTS), same shape as 053–058.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "061"
|
||||
down_revision = "060"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS jev_config_dependent DOUBLE PRECISION;")
|
||||
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS jev_checked_at TIMESTAMP;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS jev_checked_at;")
|
||||
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS jev_config_dependent;")
|
||||
@@ -17,7 +17,7 @@ from app.auth.token_revocation import is_revoked
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
@@ -87,7 +87,7 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""
|
||||
|
||||
@@ -63,6 +63,7 @@ class AuthOrchestrator:
|
||||
*,
|
||||
actor_ip: Optional[str] = None,
|
||||
actor_user_agent: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
) -> AuthResult:
|
||||
"""
|
||||
Try each credential-capable strategy in lookup_order. Returns
|
||||
@@ -111,7 +112,7 @@ class AuthOrchestrator:
|
||||
# IdP and never reach this code path.
|
||||
mfa_required = bool(user.totp_enabled and user.totp_secret)
|
||||
|
||||
self._audit_login_success(user, prov, actor_ip, actor_user_agent)
|
||||
self._audit_login_success(user, prov, actor_ip, actor_user_agent, channel)
|
||||
return AuthResult(
|
||||
user=user,
|
||||
provider=prov,
|
||||
@@ -122,7 +123,7 @@ class AuthOrchestrator:
|
||||
)
|
||||
|
||||
# All strategies failed
|
||||
self._audit_login_failure(username, provider_tried, last_error, actor_ip, actor_user_agent)
|
||||
self._audit_login_failure(username, provider_tried, last_error, actor_ip, actor_user_agent, channel)
|
||||
# Generic safe message (anti-enumeration)
|
||||
raise AuthError(
|
||||
detail=f"all providers failed for '{username}': {[p for p in provider_tried]}",
|
||||
@@ -204,7 +205,7 @@ class AuthOrchestrator:
|
||||
provisioner = JITProvisioner(self.db, default_role=self.default_role)
|
||||
return provisioner.provision(identity, actor_ip=ip)
|
||||
|
||||
def _audit_login_success(self, user, provider: AuthProvider, ip, ua):
|
||||
def _audit_login_success(self, user, provider: AuthProvider, ip, ua, channel=None):
|
||||
evt = AuditEventType.LOGIN_SUCCESS
|
||||
if provider == AuthProvider.LDAP:
|
||||
evt = AuditEventType.LOGIN_LDAP_SUCCESS
|
||||
@@ -215,7 +216,7 @@ class AuthOrchestrator:
|
||||
AuditLog(
|
||||
user_id=user.id,
|
||||
event_type=evt,
|
||||
event_description=f"login OK via {provider.value} as {user.username}",
|
||||
event_description=f"login OK via {provider.value} as {user.username}" + (f" [{channel}]" if channel else ""),
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
resource_type="user",
|
||||
@@ -232,6 +233,7 @@ class AuthOrchestrator:
|
||||
error: Optional[AuthError],
|
||||
ip,
|
||||
ua,
|
||||
channel=None,
|
||||
):
|
||||
self.db.add(
|
||||
AuditLog(
|
||||
@@ -241,6 +243,7 @@ class AuthOrchestrator:
|
||||
f"login failed for '{username}' "
|
||||
f"(tried: {', '.join(providers_tried) or 'none'}; "
|
||||
f"reason: {error.detail if error else 'no providers configured'})"
|
||||
+ (f" [{channel}]" if channel else "")
|
||||
),
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
|
||||
@@ -29,9 +29,12 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
|
||||
"smtp_config",
|
||||
"nessus_config",
|
||||
"openrouter_api_key",
|
||||
"typesafe_api_key",
|
||||
"intune_config",
|
||||
"vcenter_config",
|
||||
"igel_config",
|
||||
"netdisco_config",
|
||||
"dockhand_config",
|
||||
"github_pat",
|
||||
})
|
||||
|
||||
|
||||
+12
-2
@@ -14,11 +14,21 @@ DATABASE_URL = os.getenv(
|
||||
)
|
||||
|
||||
# Engine mit Security-Optimierungen
|
||||
#
|
||||
# pool_size + max_overflow must cover the request worker threadpool, not the
|
||||
# expected load: the endpoints are sync `def`, so FastAPI runs them in that
|
||||
# threadpool (40 threads by default) and each one holds a Session for its whole
|
||||
# body. At 30 connections the 31st concurrent request did not wait for a slower
|
||||
# query, it waited for a *connection* — and that is a timeout, not slowness.
|
||||
# Raise both together with the thread limit or not at all.
|
||||
POOL_SIZE = 20
|
||||
MAX_OVERFLOW = 20 # 20 + 20 = the 40 threads FastAPI will hand out
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_pre_ping=True, # Prüft Connection vor Nutzung
|
||||
pool_size=10, # Connection Pool Größe
|
||||
max_overflow=20, # Maximale zusätzliche Connections
|
||||
pool_size=POOL_SIZE,
|
||||
max_overflow=MAX_OVERFLOW,
|
||||
echo=False, # SQL-Logging (in Production deaktivieren)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Dockhand client (Docker management UI with built-in Grype / Trivy scans).
|
||||
|
||||
Thin httpx wrapper, no DB writes — same contract as the other integration
|
||||
clients. Read-only endpoints over Dockhand's cached scan results, plus one
|
||||
that rescans:
|
||||
|
||||
* `GET /api/environments` — the Docker hosts Dockhand manages;
|
||||
* `GET /api/host?env=<id>` — the Docker host's real hostname, for display;
|
||||
* `GET /api/images?env=<id>` — only to tell a reachable host from one
|
||||
Dockhand cannot reach (see dockhand_service);
|
||||
* `GET /api/vulnerabilities/export?env=<id>&format=json` — that host's
|
||||
findings, one row per (image, vulnerability id, package, version), for
|
||||
images still present on the host (Dockhand ≥ 1.0.37, #415);
|
||||
* `POST /api/vulnerabilities/scan-all?env=<id>` — rescans every image of
|
||||
the host, sequentially. Dockhand has no scan schedule of its own (it scans
|
||||
on pull / update / by hand), so the nightly sync calls this first.
|
||||
|
||||
Auth: `Authorization: Bearer dh_…`, an API token created in Dockhand under
|
||||
the user's profile. The user needs `images:view` (and `environments:view` to
|
||||
list the hosts; `images:inspect` for the rescan). An instance with auth
|
||||
disabled needs no token.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
# One image takes 10–60 s, a Grype DB update on the first scan longer. Bounded
|
||||
# so a hung Dockhand cannot hold the nightly job (and its lock) forever.
|
||||
SCAN_ALL_READ_TIMEOUT = 3 * 3600.0
|
||||
|
||||
|
||||
class DockhandError(RuntimeError):
|
||||
"""Connection / authentication / API failure, with Dockhand's message kept."""
|
||||
|
||||
|
||||
class DockhandClient:
|
||||
def __init__(self, base_url: str, token: str = "", verify_ssl: bool = True):
|
||||
headers = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token.strip()}"
|
||||
# Short connect, generous read: the export flattens every scan of a
|
||||
# host in one response and is slow on a big one.
|
||||
self._client = httpx.Client(
|
||||
base_url=base_url.rstrip("/"), verify=verify_ssl, headers=headers,
|
||||
timeout=httpx.Timeout(connect=8.0, read=120.0, write=30.0, pool=5.0),
|
||||
)
|
||||
|
||||
def _request(self, method: str, path: str, timeout=None, **params) -> object:
|
||||
try:
|
||||
r = self._client.request(method, path, params=params,
|
||||
timeout=timeout or self._client.timeout)
|
||||
except httpx.HTTPError as e:
|
||||
raise DockhandError(f"Dockhand unreachable: {e}") from e
|
||||
if r.status_code in (401, 403):
|
||||
raise DockhandError(f"Dockhand refused the token (HTTP {r.status_code}) — it needs "
|
||||
"images:view and environments:view (images:inspect to rescan).")
|
||||
if r.status_code >= 400:
|
||||
raise DockhandError(f"Dockhand {path} failed (HTTP {r.status_code}): {r.text[:300]}")
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError as e:
|
||||
# A reverse proxy's login page answers 200 with HTML.
|
||||
raise DockhandError(f"Dockhand {path} returned no JSON — wrong URL?") from e
|
||||
|
||||
def _get(self, path: str, **params) -> object:
|
||||
return self._request("GET", path, **params)
|
||||
|
||||
def environments(self) -> List[dict]:
|
||||
got = self._get("/api/environments")
|
||||
if not isinstance(got, list):
|
||||
raise DockhandError("Dockhand /api/environments returned no list")
|
||||
return got
|
||||
|
||||
def images(self, env_id: int) -> List[dict]:
|
||||
"""The host's images. Dockhand answers `[]` — not an error — when it
|
||||
cannot reach the Docker host."""
|
||||
got = self._get("/api/images", env=env_id)
|
||||
if not isinstance(got, list):
|
||||
raise DockhandError(f"Dockhand /api/images for environment {env_id} returned no list")
|
||||
return got
|
||||
|
||||
def host(self, env_id: int) -> dict:
|
||||
"""The Docker daemon's host: `hostname` is `docker info` Name, the real
|
||||
host's name even behind a socket or a Hawser agent."""
|
||||
got = self._get("/api/host", env=env_id)
|
||||
return got if isinstance(got, dict) else {}
|
||||
|
||||
def findings(self, env_id: int) -> List[dict]:
|
||||
got = self._get("/api/vulnerabilities/export", env=env_id, format="json")
|
||||
if not isinstance(got, dict) or not isinstance(got.get("findings"), list):
|
||||
raise DockhandError(f"Dockhand export for environment {env_id} has no findings list "
|
||||
"— Dockhand older than 1.0.37?")
|
||||
return got["findings"]
|
||||
|
||||
def scan_all(self, env_id: int) -> dict:
|
||||
"""Rescan every image of the host and wait for it. `Accept: application/json`
|
||||
makes Dockhand answer synchronously with the summary
|
||||
`{scanned, failed, total, cancelled}` (or `{success: false, error}`)
|
||||
instead of a job id."""
|
||||
got = self._request("POST", "/api/vulnerabilities/scan-all", env=env_id,
|
||||
timeout=httpx.Timeout(connect=8.0, read=SCAN_ALL_READ_TIMEOUT,
|
||||
write=30.0, pool=5.0))
|
||||
if not isinstance(got, dict):
|
||||
raise DockhandError(f"Dockhand scan-all for environment {env_id} returned no object")
|
||||
return got
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
@@ -230,6 +230,9 @@ class IgelClient:
|
||||
# inventory travelling through a vulnerability scanner for no purpose.
|
||||
return {
|
||||
"unit_id": (r.get("unitID") or "").strip(),
|
||||
# The manufacturer serial. `unitID` and `mac` are both the MAC,
|
||||
# `id` is the UMS row and `assetID` a mostly empty free-text field.
|
||||
"serial": str(r.get("deviceSerialNumber") or "").strip(),
|
||||
"firmware_id": str(r.get("firmwareID") or ""),
|
||||
"name": (r.get("name") or "").strip(),
|
||||
"network_name": (r.get("networkName") or "").strip(),
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Netdisco client (Netdisco 2 REST API, /api/v1).
|
||||
|
||||
Thin httpx wrapper, no DB writes — same contract as the other integration
|
||||
clients. One endpoint is read and nothing else:
|
||||
|
||||
* `GET /api/v1/report/device/deviceinventory` — every device Netdisco has
|
||||
discovered, one row each: name, canonical IP, location, model, serial,
|
||||
vendor, OS and OS version. That last pair is the whole point: a switch
|
||||
exposes its firmware to SNMP and to nothing else here, so this report is
|
||||
the only place an ArubaOS / AOS-CX version can come from.
|
||||
|
||||
Auth, and why BOTH forms are supported:
|
||||
|
||||
* an API key sent as `Authorization: <key>` (Netdisco's own header spelling —
|
||||
no "Bearer", no "Token"), which is what a production instance should use:
|
||||
it is per-user, revocable, and never puts a password on the wire;
|
||||
* a username + password, which are POSTed once to `/login` and exchanged for
|
||||
exactly such a key. That is Netdisco's documented way to MINT one, so the
|
||||
two are the same mechanism with a different starting point rather than two
|
||||
code paths.
|
||||
* neither, for an instance with no authentication at all — the public demo
|
||||
answers the report unauthenticated, and refusing to talk to it would make
|
||||
the connector untestable before it is pointed at production.
|
||||
|
||||
Scheme: http AND https, chosen by the operator. Netdisco's own web server
|
||||
(the Docker image included) speaks plain HTTP on port 5000 and cannot be
|
||||
configured for TLS at all; a production deployment fronts it with a reverse
|
||||
proxy that terminates HTTPS on some other port. Hard-coding either one would
|
||||
lock out half of the installations, so both are configured, and `verify_ssl`
|
||||
is separate because such a proxy often carries an internal CA's certificate.
|
||||
|
||||
API reference: https://<your-netdisco>/swagger-ui/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PORT = 5000
|
||||
INVENTORY_PATH = "/api/v1/report/device/deviceinventory"
|
||||
|
||||
|
||||
class NetdiscoError(RuntimeError):
|
||||
"""Connection / authentication failure, with Netdisco's message kept."""
|
||||
|
||||
|
||||
class NetdiscoClient:
|
||||
def __init__(self, host: str, api_key: str = "", username: str = "",
|
||||
password: str = "", port: int = DEFAULT_PORT,
|
||||
use_https: bool = False, verify_ssl: bool = True):
|
||||
self.host = _clean_host(host)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.username = (username or "").strip()
|
||||
self.password = password or ""
|
||||
self.port = int(port or DEFAULT_PORT)
|
||||
self.use_https = bool(use_https)
|
||||
scheme = "https" if use_https else "http"
|
||||
self.base_url = f"{scheme}://{self.host}:{self.port}"
|
||||
# Short connect timeout, generous read: the same reasoning as the
|
||||
# Nessus and IGEL clients — an unreachable Netdisco must fail fast
|
||||
# instead of holding a worker, while the inventory report over a
|
||||
# campus-sized estate is genuinely slow to serialise.
|
||||
self._client = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
verify=verify_ssl,
|
||||
timeout=httpx.Timeout(connect=8.0, read=120.0, write=30.0, pool=5.0),
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
self._logged_in = False
|
||||
|
||||
# ---------- connection ----------
|
||||
def login(self) -> None:
|
||||
"""Exchange username+password for an API key, once.
|
||||
|
||||
Skipped entirely when a key was configured (it already IS the result of
|
||||
this call) or when no credentials were given at all — an instance
|
||||
without authentication answers the report regardless, and demanding a
|
||||
login from it would fail on the one deployment that needs none.
|
||||
"""
|
||||
if self._logged_in or self.api_key or not self.username:
|
||||
return
|
||||
try:
|
||||
r = self._client.post("/login", auth=(self.username, self.password))
|
||||
except httpx.HTTPError as e:
|
||||
raise NetdiscoError(f"Netdisco login failed for {self.host}: {e}") from e
|
||||
if r.status_code in (401, 403):
|
||||
raise NetdiscoError("Netdisco rejected the credentials "
|
||||
f"(HTTP {r.status_code}). Check the user, and that "
|
||||
"it is allowed to use the API.")
|
||||
if r.status_code >= 400:
|
||||
raise NetdiscoError(f"Netdisco login failed (HTTP {r.status_code}): {_err(r)}")
|
||||
try:
|
||||
key = (r.json() or {}).get("api_key")
|
||||
except ValueError:
|
||||
key = None
|
||||
if not key:
|
||||
raise NetdiscoError("Netdisco login returned no api_key — the user "
|
||||
"exists but has no API access.")
|
||||
self.api_key = str(key).strip()
|
||||
self._logged_in = True
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _get(self, path: str) -> object:
|
||||
self.login()
|
||||
# Netdisco's own spelling: the raw key in `Authorization`, no scheme
|
||||
# word in front of it. Sent only when there is one — an unauthenticated
|
||||
# instance answers a header-less request and 400s on a bogus one.
|
||||
headers = {"Authorization": self.api_key} if self.api_key else None
|
||||
try:
|
||||
r = self._client.get(path, headers=headers)
|
||||
except httpx.HTTPError as e:
|
||||
raise NetdiscoError(f"Netdisco GET {path} failed: {e}") from e
|
||||
if r.status_code in (401, 403):
|
||||
raise NetdiscoError(
|
||||
f"Netdisco rejected the request (HTTP {r.status_code}) — the API "
|
||||
"key is wrong, expired, or the instance requires a login.")
|
||||
if r.status_code >= 400:
|
||||
raise NetdiscoError(f"Netdisco GET {path} failed "
|
||||
f"(HTTP {r.status_code}): {_err(r)}")
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError as e:
|
||||
# An HTML body here is the classic misconfiguration: the login page,
|
||||
# served with HTTP 200, because the request was not authenticated.
|
||||
raise NetdiscoError(
|
||||
f"Netdisco GET {path} returned no JSON (a login page, most "
|
||||
"likely — check the API key)") from e
|
||||
|
||||
# ---------- reads ----------
|
||||
def get_devices(self) -> List[dict]:
|
||||
"""Every discovered device, one call.
|
||||
|
||||
Rows with no IP are dropped here rather than by the caller: Netdisco
|
||||
keys a device on its canonical address, so a row without one cannot be
|
||||
pinned to an asset and would fork a new one on every sync.
|
||||
"""
|
||||
data = self._get(INVENTORY_PATH)
|
||||
rows = data if isinstance(data, list) else (data or {}).get("results", [])
|
||||
out = []
|
||||
for r in rows:
|
||||
if isinstance(r, dict) and (r.get("ip") or "").strip():
|
||||
out.append(self._device_dict(r))
|
||||
return out
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
"""Credential + reachability probe for the settings UI."""
|
||||
try:
|
||||
self.login()
|
||||
except NetdiscoError as e:
|
||||
return {"ok": False, "step": "login", "error": str(e)}
|
||||
try:
|
||||
devices = self.get_devices()
|
||||
except NetdiscoError as e:
|
||||
return {"ok": False, "step": "inventory", "error": str(e)}
|
||||
from app.services.netdisco_service import firmware_family
|
||||
# Devices on a firmware line we can actually decide — the number that
|
||||
# says whether pointing the connector here will produce findings, not
|
||||
# just assets.
|
||||
matched = sum(1 for d in devices if firmware_family(d))
|
||||
return {"ok": True, "device_count": len(devices), "fw_count": matched,
|
||||
"auth": "api-key" if self.api_key else "none",
|
||||
"url": self.base_url + INVENTORY_PATH}
|
||||
|
||||
@staticmethod
|
||||
def _device_dict(r: dict) -> dict:
|
||||
# Netdisco's column names, mapped once here so nothing downstream has to
|
||||
# know them. `version` is the OS VERSION column of the report (the
|
||||
# firmware string, "WC.16.11.0016"), not a Netdisco version.
|
||||
return {
|
||||
"ip": (r.get("ip") or "").strip(),
|
||||
"name": (r.get("device_name") or "").strip(),
|
||||
"details": (r.get("device_details") or "").strip(),
|
||||
"location": (r.get("location") or "").strip().strip('"'),
|
||||
"model": (r.get("model") or "").strip(),
|
||||
"serial": (r.get("serial") or "").strip(),
|
||||
"vendor": (r.get("vendor") or "").strip(),
|
||||
"os": (r.get("os") or "").strip(),
|
||||
"os_version": (r.get("version") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _clean_host(host: str) -> str:
|
||||
return ((host or "").strip()
|
||||
.replace("https://", "").replace("http://", "")
|
||||
.rstrip("/").split("/")[0])
|
||||
|
||||
|
||||
def _err(r: httpx.Response) -> str:
|
||||
"""Netdisco's error message, which lives in the body, not the status line."""
|
||||
try:
|
||||
body = r.json()
|
||||
if isinstance(body, dict):
|
||||
return str(body.get("error") or body.get("message") or body)[:300]
|
||||
except Exception:
|
||||
pass
|
||||
return (r.text or "")[:300]
|
||||
@@ -7,7 +7,7 @@ clients. Two things are read and nothing else:
|
||||
and instanceUuid;
|
||||
* every ESXi host in the inventory — one PropertyCollector pass over
|
||||
`HostSystem` for name, product version + build, management IP, hardware
|
||||
model and connection state.
|
||||
model, UUID and serial, and connection state.
|
||||
|
||||
Why pyVmomi and not the REST API: `GET /api/vcenter/host` returns a host's
|
||||
name, power and connection state — and no version or build at all. The build
|
||||
@@ -38,9 +38,10 @@ _HOST_PROPS = [
|
||||
"config.product.build",
|
||||
"config.product.fullName",
|
||||
"config.network.vnic",
|
||||
"hardware.systemInfo.uuid",
|
||||
"hardware.systemInfo.vendor",
|
||||
"hardware.systemInfo.model",
|
||||
# The whole HostSystemInfo, not its leaves: `serialNumber` only exists from
|
||||
# API 6.7 on, and a property path the server does not know faults the
|
||||
# entire RetrieveContents — every host, not just the one field.
|
||||
"hardware.systemInfo",
|
||||
"runtime.connectionState",
|
||||
]
|
||||
|
||||
@@ -152,19 +153,48 @@ class VCenterClient:
|
||||
@staticmethod
|
||||
def _host_dict(obj) -> dict:
|
||||
props = {p.name: p.val for p in (obj.propSet or [])}
|
||||
si = props.get("hardware.systemInfo")
|
||||
return {
|
||||
"name": props.get("name"),
|
||||
"version": props.get("config.product.version"),
|
||||
"build": props.get("config.product.build"),
|
||||
"full_name": props.get("config.product.fullName"),
|
||||
"ip_address": _vmk_ip(props.get("config.network.vnic")),
|
||||
"uuid": props.get("hardware.systemInfo.uuid"),
|
||||
"vendor": props.get("hardware.systemInfo.vendor"),
|
||||
"model": props.get("hardware.systemInfo.model"),
|
||||
"uuid": getattr(si, "uuid", None),
|
||||
"vendor": getattr(si, "vendor", None),
|
||||
"model": getattr(si, "model", None),
|
||||
"serial": _host_serial(si),
|
||||
"connection_state": str(props.get("runtime.connectionState") or ""),
|
||||
}
|
||||
|
||||
|
||||
# Where a server's chassis serial hides when `serialNumber` is empty (API < 6.7,
|
||||
# or a BIOS that only fills the SMBIOS tags): Dell reports its service tag as
|
||||
# ServiceTag, HPE and Lenovo as SerialNumberTag / EnclosureSerialNumberTag.
|
||||
# AssetTag is left out on purpose — it is free text an operator types into
|
||||
# the BIOS, not the manufacturer's number.
|
||||
_SERIAL_TAGS = ("SerialNumberTag", "EnclosureSerialNumberTag", "ServiceTag")
|
||||
|
||||
|
||||
def _host_serial(si) -> Optional[str]:
|
||||
"""The ESXi host's hardware serial, or None when the box reports none."""
|
||||
from app.services.asset_matching import stable_serial
|
||||
if si is None:
|
||||
return None
|
||||
serial = stable_serial(getattr(si, "serialNumber", None))
|
||||
if serial:
|
||||
return serial
|
||||
tags = {}
|
||||
for info in getattr(si, "otherIdentifyingInfo", None) or []:
|
||||
key = getattr(getattr(info, "identifierType", None), "key", None)
|
||||
tags.setdefault(key, getattr(info, "identifierValue", None))
|
||||
for key in _SERIAL_TAGS:
|
||||
serial = stable_serial(tags.get(key))
|
||||
if serial:
|
||||
return serial
|
||||
return None
|
||||
|
||||
|
||||
def _vmk_ip(vnics) -> Optional[str]:
|
||||
"""Management IP from the host's VMkernel adapters.
|
||||
|
||||
|
||||
@@ -142,6 +142,12 @@ class WazuhClient:
|
||||
logger.error(f"Wazuh Auth error: {e}")
|
||||
raise WazuhAuthenticationError(f"Authentication failed: {e}")
|
||||
|
||||
def login(self) -> None:
|
||||
"""Authenticate now (or reuse a live token). The jobs that read the
|
||||
manager API per asset call this once up front, so a refused login is
|
||||
one recorded failure instead of a warning per asset."""
|
||||
self._ensure_authenticated()
|
||||
|
||||
def _ensure_authenticated(self) -> str:
|
||||
"""
|
||||
Stellt sicher, dass ein gültiger Token vorhanden ist
|
||||
@@ -576,9 +582,16 @@ class WazuhClient:
|
||||
|
||||
logger.info(f"Agent {agent_id}: Returning {len(results)} vulnerabilities from indexer")
|
||||
return results
|
||||
except WazuhAPIError:
|
||||
raise
|
||||
except Exception as e:
|
||||
# An agent whose CVE list could not be read is NOT an agent with
|
||||
# no CVEs. This used to log and return [] — with a wrong indexer
|
||||
# password every agent came back "clean", every caller counted it
|
||||
# as synced, and the sync run finished COMPLETED (2026-09-06).
|
||||
# Ingest fails loud: every caller already catches per agent.
|
||||
logger.error(f"Fehler beim Abfragen des Indexers fuer Agent {agent_id}: {e}")
|
||||
return []
|
||||
raise WazuhAPIError(f"Indexer query failed for agent {agent_id}: {e}")
|
||||
|
||||
def get_vulnerability_summary(self, agent_id: str) -> Dict[str, int]:
|
||||
"""
|
||||
@@ -708,10 +721,21 @@ class WazuhClient:
|
||||
resp = self._indexer_request(
|
||||
"POST", f"{self._EXT_INDEX}/_search", json_data=body)
|
||||
except WazuhAPIError as e:
|
||||
# IT Hygiene is optional and only exists from 4.14 — a missing
|
||||
# index must not take the whole scan down with it.
|
||||
logger.debug("browser extensions unavailable for %s: %s", agent_id, e)
|
||||
return []
|
||||
# IT Hygiene is optional and only exists from 4.14 — a MISSING
|
||||
# index must not take the whole scan down with it, so that one
|
||||
# answers "no extensions".
|
||||
#
|
||||
# Anything else must not. An indexer that is down or refusing auth
|
||||
# also came back here, and returning [] told the scanner this host
|
||||
# runs no extensions at all — whereupon its reconcile closed every
|
||||
# extension finding on it as "no longer detected". Unreachable is
|
||||
# not empty; say so and let the caller decide.
|
||||
msg = str(e)
|
||||
if "404" in msg or "index_not_found" in msg:
|
||||
logger.debug("browser extensions unavailable for %s: %s", agent_id, e)
|
||||
return []
|
||||
logger.warning("browser extension fetch failed for %s: %s", agent_id, e)
|
||||
raise
|
||||
out: List[Dict[str, Any]] = []
|
||||
for hit in (resp.get("hits", {}) or {}).get("hits", []) or []:
|
||||
src = hit.get("_source") or {}
|
||||
|
||||
+22
-5
@@ -29,7 +29,7 @@ from slowapi.errors import RateLimitExceeded
|
||||
|
||||
|
||||
|
||||
from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance, intune, vcenter, igel, advisories
|
||||
from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance, intune, vcenter, igel, netdisco, dockhand, advisories
|
||||
try:
|
||||
from app.routers import auth_oidc
|
||||
_HAS_OIDC = True
|
||||
@@ -191,16 +191,31 @@ def _safe_query_string(query: str) -> str:
|
||||
return "?" + "&".join(parts)
|
||||
|
||||
|
||||
# Anything slower than this is worth finding in a log by eye. The dashboard
|
||||
# fires thirteen requests at once, so "the page is slow" is only actionable
|
||||
# once a single line says WHICH of them was slow.
|
||||
SLOW_REQUEST_MS = 1000
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
safe_target = f"{request.url.path}{_safe_query_string(request.url.query)}"
|
||||
logger.info(f"➡️ Incoming Request: {request.method} {safe_target}")
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
logger.info(f"⬅️ Response: {response.status_code}")
|
||||
# The path and the duration belong on the RESPONSE line, not just the
|
||||
# request one. Concurrent requests interleave — the dashboard alone
|
||||
# issues thirteen — so a bare "⬅️ Response: 200" cannot be tied back to
|
||||
# the request it answers, and a report of "the page loads slowly" had
|
||||
# no line to point at.
|
||||
ms = (time.perf_counter() - started) * 1000
|
||||
line = f"⬅️ Response: {response.status_code} {request.method} {safe_target} in {ms:.0f}ms"
|
||||
logger.warning(f"🐢 SLOW {line}") if ms >= SLOW_REQUEST_MS else logger.info(line)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Request Failed: {e}")
|
||||
ms = (time.perf_counter() - started) * 1000
|
||||
logger.error(f"❌ Request Failed after {ms:.0f}ms: {request.method} {safe_target}: {e}")
|
||||
raise
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -293,7 +308,7 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
# ============================================
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
def health_check():
|
||||
"""
|
||||
Health-Check-Endpoint für Load-Balancer & Monitoring.
|
||||
|
||||
@@ -306,7 +321,7 @@ async def health_check():
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
@limiter.limit("10/minute")
|
||||
async def root(request: Request):
|
||||
def root(request: Request):
|
||||
"""
|
||||
Root-Endpoint mit API-Info
|
||||
"""
|
||||
@@ -333,6 +348,8 @@ app.include_router(nessus.router)
|
||||
app.include_router(intune.router)
|
||||
app.include_router(vcenter.router)
|
||||
app.include_router(igel.router)
|
||||
app.include_router(netdisco.router)
|
||||
app.include_router(dockhand.router)
|
||||
app.include_router(advisories.router)
|
||||
app.include_router(assets.router)
|
||||
app.include_router(policies.router)
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.models.policy import Policy
|
||||
from app.models.scan_schedule import ScanSchedule
|
||||
from app.models.notification_log import NotificationLog
|
||||
from app.models.setting import Setting
|
||||
from app.models.sync_run import SyncRun
|
||||
from app.models.ai_report import AIReport
|
||||
from app.models.cve_remediation import CveRemediation
|
||||
from app.models.app_cve_cache import AppCveCache
|
||||
@@ -33,6 +34,7 @@ __all__ = [
|
||||
"ScanSchedule",
|
||||
"NotificationLog",
|
||||
"Setting",
|
||||
"SyncRun",
|
||||
"AIReport",
|
||||
"CveRemediation",
|
||||
"AppCveCache",
|
||||
|
||||
+44
-1
@@ -23,6 +23,8 @@ class AssetSource(str, Enum):
|
||||
INTUNE = "INTUNE"
|
||||
VCENTER = "VCENTER"
|
||||
IGEL = "IGEL"
|
||||
NETDISCO = "NETDISCO"
|
||||
CONTAINER = "CONTAINER" # one asset per image, Trivy/Grype push (059)
|
||||
|
||||
|
||||
class AssetStatus(str, Enum):
|
||||
@@ -58,6 +60,12 @@ class Asset(Base, TimestampMixin):
|
||||
# Graph syncs reconnect deterministically. defender_machine_id maps the
|
||||
# asset to its Microsoft Defender for Endpoint machine (TVM, phase 3).
|
||||
intune_device_id = Column(String(64), nullable=True, index=True)
|
||||
# Hardware serial as Intune reports it. The device id is NOT stable — a
|
||||
# wipe-and-re-enrol issues a new one for the same hardware — so the serial
|
||||
# is what tells a re-enrolment (same serial, keep the asset and its
|
||||
# history) from two devices that merely share a name (different serials,
|
||||
# two assets). Same role netdisco_serial plays for a switch.
|
||||
intune_serial = Column(String(64), nullable=True, index=True)
|
||||
defender_machine_id = Column(String(64), nullable=True, index=True)
|
||||
# Entra/AAD device id — the stable anchor shared by Intune (azureADDeviceId)
|
||||
# and Defender (aadDeviceId); merges the same physical device across both.
|
||||
@@ -71,11 +79,31 @@ class Asset(Base, TimestampMixin):
|
||||
# vmware_release_service.
|
||||
vmware_uuid = Column(String(64), nullable=True, index=True)
|
||||
vmware_build = Column(String(32), nullable=True)
|
||||
# The ESXi host's hardware serial (service tag), as vCenter reports it.
|
||||
# Shown to the operator only — the UUID stays the match key (056).
|
||||
vmware_serial = Column(String(64), nullable=True)
|
||||
# IGEL UMS unit ID of an endpoint device (MAC-derived, and the identifier
|
||||
# UMS itself keys on — it survives renames, re-registration and a UMS
|
||||
# restore), or the UMS server's own `serverUUID`. The pin that reconnects
|
||||
# the asset on every sync.
|
||||
igel_unit_id = Column(String(64), nullable=True, index=True)
|
||||
# IMI `deviceSerialNumber` — the manufacturer serial. `unitID` and `mac`
|
||||
# are the same value (the MAC), so a board/NIC swap or a UD Pocket
|
||||
# re-registration changes the unit ID while the hardware stays; the serial
|
||||
# is what finds the asset again.
|
||||
igel_serial = Column(String(64), nullable=True, index=True)
|
||||
# Netdisco's canonical device IP — the address Netdisco itself keys a
|
||||
# device on, statically configured on a switch's management interface and
|
||||
# stable across the renames switches do get. The pin that reconnects the
|
||||
# asset on every sync.
|
||||
netdisco_device_ip = Column(String(45), nullable=True, index=True)
|
||||
# …and the device's serial number, which is the identity the address only
|
||||
# stands in for: a switch keeps its management address, an access point on
|
||||
# a DHCP lease does not, and a re-addressed device used to fork a second
|
||||
# asset while the first went INACTIVE. Pinned once and then left alone —
|
||||
# stack members share a name and an address but bring a serial each, and
|
||||
# the asset keeps the first one it saw.
|
||||
netdisco_serial = Column(String(64), nullable=True, index=True)
|
||||
|
||||
# System-Information
|
||||
operating_system = Column(String(255), nullable=True)
|
||||
@@ -121,7 +149,7 @@ class Asset(Base, TimestampMixin):
|
||||
last_scan = Column(DateTime, nullable=True)
|
||||
last_seen = Column(DateTime, nullable=True)
|
||||
# Which sync wrote the stamps above ("wazuh" | "nessus" | "intune" |
|
||||
# "defender" | "vcenter" | "igel" | "manual"). Without it the nightly
|
||||
# "defender" | "vcenter" | "igel" | "netdisco" | "manual"). Without it the
|
||||
# time-based reconcile could only say "seen again by a source sync" — an
|
||||
# audit line an operator cannot act on, because an iPhone that Intune has
|
||||
# deleted and Defender TVM still reports looks identical to one Intune
|
||||
@@ -158,3 +186,18 @@ class Asset(Base, TimestampMixin):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Asset(hostname='{self.hostname}', ip='{self.ip_address}')>"
|
||||
|
||||
|
||||
class ContainerPlacement(Base):
|
||||
"""A container image (CONTAINER asset) runs on this host asset, as these
|
||||
containers. Written by the Dockhand sync only, and only where the Docker
|
||||
host's own hostname names exactly one host asset (060). Rewritten on every
|
||||
sync, so an image gone from the host drops its row with the next run."""
|
||||
__tablename__ = "container_placements"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
image_asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True)
|
||||
host_asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True)
|
||||
containers = Column(Text, nullable=True) # "nginx, web-1" — display only
|
||||
|
||||
@@ -17,6 +17,8 @@ class NotificationType(str, Enum):
|
||||
# Actively exploited (CISA KEV / ENISA EUVD) AND present in our inventory —
|
||||
# its own type so the log can be filtered for the alerts that meant "now".
|
||||
KEV_ALERT = "kev_alert"
|
||||
# A sync job (Wazuh/Nessus/Intune/vCenter/IGEL) failed — coverage gap.
|
||||
SYNC_FAILURE = "sync_failure"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""SyncRun — one row per sync job execution, whatever the source or trigger.
|
||||
|
||||
The `scans` table records per-asset results, so a sync that dies BEFORE it
|
||||
reaches any asset (Wazuh authentication refused, Nessus unreachable, IGEL UMS
|
||||
down) left no trace anywhere but the container log. This ledger is the
|
||||
source-level record: started, finished, status, and the error that stopped it.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class SyncRun(Base):
|
||||
__tablename__ = "sync_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# wazuh | nessus | intune | vcenter | igel
|
||||
source = Column(String(30), nullable=False, index=True)
|
||||
# manual | scheduled
|
||||
trigger = Column(String(20), nullable=False, default="manual")
|
||||
# running | completed | failed (plain strings: no Postgres enum to migrate)
|
||||
status = Column(String(20), nullable=False, default="running", index=True)
|
||||
started_at = Column(DateTime, nullable=False)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
# JSON: the service's stats dict (agents synced, created, errors[] ...)
|
||||
stats = Column(Text, nullable=True)
|
||||
# When a failure mail went out for this run — the cooldown anchor.
|
||||
alerted_at = Column(DateTime, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SyncRun(source='{self.source}', status='{self.status}')>"
|
||||
@@ -37,6 +37,9 @@ class VulnerabilityStatus(str, Enum):
|
||||
# and a genuinely critical finding sorted below a medium with a score. Taking
|
||||
# the band's FLOOR keeps the estimate conservative: it can only ever
|
||||
# understate a real score, never inflate one.
|
||||
# Every vendor scale seen: FIRST's and Microsoft's (TeamViewer uses it too).
|
||||
_VENDOR_RATINGS = {"critical", "important", "high", "moderate", "medium", "low"}
|
||||
|
||||
_SEVERITY_FLOOR = {"critical": 9.0, "high": 7.0, "medium": 4.0, "low": 0.1}
|
||||
|
||||
|
||||
@@ -88,6 +91,12 @@ class Vulnerability(Base, TimestampMixin):
|
||||
# CVSS-Bewertung
|
||||
cvss_score = Column(Float, nullable=True, index=True)
|
||||
cvss_vector = Column(String(100), nullable=True)
|
||||
# The vendors' own ratings, JSON {"Microsoft": "Critical", ...}, written
|
||||
# through set_vendor_severity. `severity` follows the CVSS, and vendors rate
|
||||
# by their own scale — CVE-2026-85921 is CVSS 8.2 (High) but Critical at
|
||||
# MSRC. Its own column, not appended to `description` like Mozilla's:
|
||||
# M365 and Nessus rewrite that text every night.
|
||||
vendor_severity = Column(Text, nullable=True)
|
||||
severity = Column(
|
||||
SQLEnum(VulnerabilitySeverity),
|
||||
nullable=False,
|
||||
@@ -145,6 +154,10 @@ class Vulnerability(Base, TimestampMixin):
|
||||
|
||||
# Notification Control
|
||||
notification_suppressed = Column(Boolean, default=False, nullable=False)
|
||||
# Imported with a container image repository's first report: already
|
||||
# there before anyone looked, so no new-finding mail (059). Not a mute —
|
||||
# SLA and KEV alerts still apply.
|
||||
baseline = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||
|
||||
# Enrichment: EPSS (Exploit Prediction Scoring System - FIRST.org)
|
||||
epss_score = Column(Float, nullable=True, index=True) # 0.0 - 1.0 probability of exploitation in next 30 days
|
||||
@@ -203,6 +216,13 @@ class Vulnerability(Base, TimestampMixin):
|
||||
metasploit_module_count = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
exploit_intel_updated_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Jev (TypeSafe, direct or via OpenRouter Decisions) — probability that the CVE is
|
||||
# only exploitable with an optional feature / non-default configuration.
|
||||
# A HINT for the operator to check, per CVE text: it never closes a
|
||||
# finding and never enters priority_score. See jev_triage_service.
|
||||
jev_config_dependent = Column(Float, nullable=True)
|
||||
jev_checked_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
asset = relationship("Asset", back_populates="vulnerabilities")
|
||||
assigned_user = relationship("User", foreign_keys=[assigned_user_id])
|
||||
@@ -266,6 +286,39 @@ class Vulnerability(Base, TimestampMixin):
|
||||
self.sources = json.dumps(current)
|
||||
return True
|
||||
|
||||
@property
|
||||
def reference_list(self) -> list:
|
||||
"""Parsed `references` JSON column (URLs). Empty on malformed JSON."""
|
||||
import json
|
||||
if not self.references:
|
||||
return []
|
||||
try:
|
||||
value = json.loads(self.references)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [u for u in value if isinstance(u, str)]
|
||||
|
||||
def add_reference(self, url: str) -> bool:
|
||||
"""Append `url` to `references` if not already present. Returns True
|
||||
if the list changed. Caller is responsible for committing.
|
||||
|
||||
The column is shared: Nessus writes its see_also here, vendor-bulletin
|
||||
scanners their advisory, the M365 check its release-notes section. So
|
||||
a source adds its own evidence and never drops another's — the
|
||||
cross-source contract holds for evidence, not just for `sources`.
|
||||
"""
|
||||
import json
|
||||
if not url:
|
||||
return False
|
||||
current = self.reference_list
|
||||
if url in current:
|
||||
return False
|
||||
current.append(url)
|
||||
self.references = json.dumps(current)
|
||||
return True
|
||||
|
||||
def remove_source(self, scanner: str) -> bool:
|
||||
"""Drop `scanner` from `sources`. Returns True if list changed.
|
||||
Caller is responsible for committing."""
|
||||
@@ -277,6 +330,30 @@ class Vulnerability(Base, TimestampMixin):
|
||||
self.sources = json.dumps(current)
|
||||
return True
|
||||
|
||||
@property
|
||||
def vendor_severities(self) -> dict:
|
||||
import json
|
||||
try:
|
||||
got = json.loads(self.vendor_severity or "{}")
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return got if isinstance(got, dict) else {}
|
||||
|
||||
def set_vendor_severity(self, vendor: str, rating) -> bool:
|
||||
"""Record `vendor`'s rating, title-cased. Words off the scale
|
||||
("Informative") or none at all are not a rating and never remove
|
||||
one: a bulletin that says nothing is not a retraction."""
|
||||
import json
|
||||
word = str(rating or "").strip().title()
|
||||
if not vendor or word.lower() not in _VENDOR_RATINGS:
|
||||
return False
|
||||
cur = self.vendor_severities
|
||||
if cur.get(vendor) == word:
|
||||
return False
|
||||
cur[vendor] = word
|
||||
self.vendor_severity = json.dumps(cur, sort_keys=True)
|
||||
return True
|
||||
|
||||
def sync_severity_with_cvss(self) -> bool:
|
||||
"""Keep `severity` consistent with `cvss_score`.
|
||||
|
||||
|
||||
@@ -60,16 +60,28 @@ def kev_recent(
|
||||
"""Most recently listed actively-exploited CVEs across every KEV source
|
||||
(CISA KEV + ENISA EUVD + VulnCheck KEV), newest first, annotated with
|
||||
inventory impact (open vs already-patched assets, active assets only)."""
|
||||
from app.services.kev_service import KEV_SOURCES, get_recent_kev
|
||||
from app.services.kev_service import KEV_SOURCES, get_recent_kev, source_status
|
||||
picked = [s.strip() for s in sources.split(",")] if sources else None
|
||||
status = source_status(db)
|
||||
return {
|
||||
"items": get_recent_kev(db, limit=limit, sources=picked,
|
||||
in_inventory_only=in_inventory_only),
|
||||
"sources": [{"id": k, "label": v.label, "url": v.url}
|
||||
"sources": [{"id": k, "label": v.label, "url": v.url, **status[k]}
|
||||
for k, v in KEV_SOURCES.items()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/kev/refresh")
|
||||
# Sync def → worker threadpool; three catalog downloads are blocking I/O.
|
||||
def refresh_kev(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Re-fetch every KEV catalog (CISA, ENISA EUVD, VulnCheck) now."""
|
||||
from app.services.kev_service import refresh_kev_sources
|
||||
return refresh_kev_sources(db)
|
||||
|
||||
|
||||
@router.get("/kev-alerts")
|
||||
def kev_alert_preview(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
+192
-53
@@ -13,7 +13,7 @@ from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus, ContainerPlacement
|
||||
from app.models.policy import Policy
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
from app.models.audit_log import AuditLog, AuditEventType
|
||||
@@ -46,6 +46,16 @@ class AssetResponse(BaseModel):
|
||||
# name. Read-only in the UI; it is what an operator needs to exclude the
|
||||
# right machine in Defender (field report 2026-08-30).
|
||||
aad_device_id: Optional[str] = None
|
||||
# The remaining per-source identity pins, read-only in the edit dialog so
|
||||
# an operator can see what a sync reconnects the asset by (and the serial
|
||||
# to find the hardware with).
|
||||
vmware_uuid: Optional[str] = None
|
||||
vmware_serial: Optional[str] = None
|
||||
igel_unit_id: Optional[str] = None
|
||||
igel_serial: Optional[str] = None
|
||||
netdisco_device_ip: Optional[str] = None
|
||||
netdisco_serial: Optional[str] = None
|
||||
intune_serial: Optional[str] = None
|
||||
operating_system: Optional[str]
|
||||
os_version: Optional[str]
|
||||
# VMware VMkernel/vpxd build number — the precise fix identifier for
|
||||
@@ -76,6 +86,10 @@ class AssetResponse(BaseModel):
|
||||
# Risk Dimensions (crown-jewel roles)
|
||||
high_value_score: Optional[float] = None
|
||||
risk_dimensions: Optional[List[dict]] = None
|
||||
# Docker host ↔ image links from the Dockhand sync (container_placements):
|
||||
# a host's number of images, an image's hosts as {id, hostname}.
|
||||
container_image_count: int = 0
|
||||
runs_on: List[dict] = []
|
||||
|
||||
@field_validator('exposed_services', 'risk_dimensions', mode='before')
|
||||
@classmethod
|
||||
@@ -149,7 +163,7 @@ class BulkAssetUpdateRequest(BaseModel):
|
||||
# ============================================
|
||||
|
||||
@router.post("/bulk-update")
|
||||
async def bulk_update_assets(
|
||||
def bulk_update_assets(
|
||||
update_data: BulkAssetUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -206,7 +220,7 @@ async def bulk_update_assets(
|
||||
|
||||
|
||||
@router.post("/reconcile-lifecycle")
|
||||
async def reconcile_lifecycle(
|
||||
def reconcile_lifecycle(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
@@ -396,6 +410,33 @@ def asset_software(
|
||||
"count": len(out), "software": out}
|
||||
|
||||
|
||||
@router.get("/{asset_id}/containers")
|
||||
def asset_containers(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""The container images Dockhand reports on this Docker host, with their
|
||||
containers and open findings. Only images whose Docker host's hostname
|
||||
names this asset (container_placements); clean images are not in
|
||||
Dockhand's export and so not here either."""
|
||||
host = db.query(Asset).filter(Asset.id == asset_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
open_count = (db.query(func.count(Vulnerability.id))
|
||||
.filter(Vulnerability.asset_id == Asset.id,
|
||||
Vulnerability.status == VulnerabilityStatus.open)
|
||||
.correlate(Asset).scalar_subquery())
|
||||
rows = (db.query(Asset, ContainerPlacement.containers, open_count)
|
||||
.join(ContainerPlacement, ContainerPlacement.image_asset_id == Asset.id)
|
||||
.filter(ContainerPlacement.host_asset_id == asset_id)
|
||||
.order_by(func.lower(Asset.hostname)).all())
|
||||
return {"hostname": host.hostname, "images": [
|
||||
{"id": a.id, "image": a.hostname, "status": a.status, "containers": containers,
|
||||
"vulnerability_count": n or 0, "last_scan": a.last_scan}
|
||||
for a, containers, n in rows]}
|
||||
|
||||
|
||||
@router.post("/refresh-exposure")
|
||||
def refresh_exposure(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -433,7 +474,8 @@ def refresh_exposure(
|
||||
return refresh_all_exposure(db, wazuh)
|
||||
|
||||
|
||||
def _apply_asset_filters(query, db, status=None, source=None, search=None, include_inactive=False):
|
||||
def _apply_asset_filters(query, db, status=None, source=None, search=None, include_inactive=False,
|
||||
include_containers=True):
|
||||
"""Shared filter logic for the list endpoint and the CSV export.
|
||||
|
||||
Kept in one place so the two views can never drift apart. Default shows
|
||||
@@ -483,6 +525,12 @@ def _apply_asset_filters(query, db, status=None, source=None, search=None, inclu
|
||||
# so the pin column is the signal - same as vCenter and Intune.
|
||||
query = query.filter(
|
||||
Asset.igel_unit_id.isnot(None) | (Asset.source == AssetSource.IGEL))
|
||||
elif source == AssetSource.NETDISCO:
|
||||
# A switch carries no findings until the first firmware scan, and
|
||||
# one another source registered first keeps that source - so the
|
||||
# pin column is the signal, same as vCenter and IGEL.
|
||||
query = query.filter(
|
||||
Asset.netdisco_device_ip.isnot(None) | (Asset.source == AssetSource.NETDISCO))
|
||||
elif source == AssetSource.MANUAL:
|
||||
# genuinely manual = no scanner linkage AND no scanner-sourced vuln
|
||||
query = query.filter(
|
||||
@@ -490,10 +538,14 @@ def _apply_asset_filters(query, db, status=None, source=None, search=None, inclu
|
||||
Asset.intune_device_id.is_(None), Asset.defender_machine_id.is_(None),
|
||||
Asset.vmware_uuid.is_(None), Asset.source != AssetSource.VCENTER,
|
||||
Asset.igel_unit_id.is_(None), Asset.source != AssetSource.IGEL,
|
||||
Asset.netdisco_device_ip.is_(None), Asset.source != AssetSource.NETDISCO,
|
||||
Asset.source != AssetSource.CONTAINER,
|
||||
~_has_vuln_source("wazuh"), ~_has_vuln_source("nessus"),
|
||||
~_has_vuln_source("intune"), ~_has_vuln_source("defender"))
|
||||
else:
|
||||
query = query.filter(Asset.source == source)
|
||||
elif not include_containers:
|
||||
query = query.filter(Asset.source != AssetSource.CONTAINER)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
@@ -506,11 +558,12 @@ def _apply_asset_filters(query, db, status=None, source=None, search=None, inclu
|
||||
|
||||
|
||||
@router.get("", response_model=List[AssetResponse])
|
||||
async def list_assets(
|
||||
def list_assets(
|
||||
status: Optional[AssetStatus] = Query(None),
|
||||
source: Optional[AssetSource] = Query(None),
|
||||
search: Optional[str] = Query(None, description="Suche in Hostname, IP"),
|
||||
include_inactive: bool = Query(False, description="Also include DECOMMISSIONED assets (INACTIVE are shown by default)"),
|
||||
include_containers: bool = Query(True, description="false hides container images (unless source=CONTAINER)"),
|
||||
sort_by: str = Query("hostname", description="hostname, ip_address, operating_system, status, last_scan, network_exposure_score, policy_name, assigned_user_name"),
|
||||
sort_order: str = Query("asc", description="asc, desc"),
|
||||
limit: int = Query(100, le=1000),
|
||||
@@ -530,7 +583,8 @@ async def list_assets(
|
||||
"""
|
||||
query = db.query(Asset)
|
||||
query = _apply_asset_filters(query, db, status=status, source=source,
|
||||
search=search, include_inactive=include_inactive)
|
||||
search=search, include_inactive=include_inactive,
|
||||
include_containers=include_containers)
|
||||
|
||||
# Whitelist-driven ORDER BY. Always map to a real SQLAlchemy column;
|
||||
# never pass `sort_by` straight to .order_by() (SQL-injection surface).
|
||||
@@ -577,20 +631,40 @@ async def list_assets(
|
||||
|
||||
assets = query.offset(offset).limit(limit).all()
|
||||
|
||||
# Vulnerability-Counts + assigned user info
|
||||
results = []
|
||||
# Vulnerability-Counts + assigned user info.
|
||||
#
|
||||
# ONE grouped query, not one COUNT per asset. This loop used to issue a
|
||||
# separate `SELECT count(*) FROM vulnerabilities WHERE asset_id = ?` per
|
||||
# row, so a page of 1000 assets meant 1000 round trips and the request
|
||||
# took the better part of a minute (observed in the backend log:
|
||||
# GET /api/v1/assets?limit=1000 dominating the page load).
|
||||
counts = {}
|
||||
if assets:
|
||||
counts = dict(
|
||||
db.query(Vulnerability.asset_id, func.count(Vulnerability.id))
|
||||
.filter(Vulnerability.asset_id.in_([a.id for a in assets]),
|
||||
Vulnerability.status == VulnerabilityStatus.open)
|
||||
.group_by(Vulnerability.asset_id).all())
|
||||
# Docker host ↔ image links, both directions in one query.
|
||||
image_counts, runs_on = {}, {}
|
||||
if assets:
|
||||
ids = [a.id for a in assets]
|
||||
for image_id, host_id, host_name in (
|
||||
db.query(ContainerPlacement.image_asset_id, Asset.id, Asset.hostname)
|
||||
.join(Asset, Asset.id == ContainerPlacement.host_asset_id)
|
||||
.filter(ContainerPlacement.host_asset_id.in_(ids)
|
||||
| ContainerPlacement.image_asset_id.in_(ids))
|
||||
.order_by(func.lower(Asset.hostname))):
|
||||
image_counts[host_id] = image_counts.get(host_id, 0) + 1
|
||||
runs_on.setdefault(image_id, []).append({"id": host_id, "hostname": host_name})
|
||||
for asset in assets:
|
||||
vuln_count = db.query(Vulnerability).filter(
|
||||
Vulnerability.asset_id == asset.id,
|
||||
Vulnerability.status == VulnerabilityStatus.open
|
||||
).count()
|
||||
|
||||
asset.vulnerability_count = vuln_count
|
||||
asset.vulnerability_count = counts.get(asset.id, 0)
|
||||
asset.container_image_count = image_counts.get(asset.id, 0)
|
||||
asset.runs_on = runs_on.get(asset.id, [])
|
||||
asset.assigned_user_name = asset.assigned_user.username if asset.assigned_user else None
|
||||
asset.policy_name = asset.policy.name if asset.policy else None
|
||||
results.append(asset)
|
||||
|
||||
return results
|
||||
return assets
|
||||
|
||||
|
||||
def _csv_json_list(raw):
|
||||
@@ -641,12 +715,41 @@ def _fmt_risk_dimensions(dims):
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
@router.get("/names")
|
||||
def list_asset_names(
|
||||
include_inactive: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Every asset as {id, hostname} — for host pickers. No cap, no counts.
|
||||
|
||||
The host dropdown on the Vulnerabilities page used to fetch the full asset
|
||||
list at `limit=1000`, which is the endpoint's hard maximum. With 1194
|
||||
assets in the estate the list simply stopped at the letter "M" and the
|
||||
remaining 194 hosts could not be selected at all — a silent cap on a
|
||||
picker, which is the worst place for one.
|
||||
|
||||
Fetching the full rows was also far more than a picker needs: a whole
|
||||
AssetResponse each, with the open-vulnerability count behind it. Two
|
||||
columns, one query, ordered the same way the table is (case-insensitively,
|
||||
so "alpine" does not outrank "Webserver").
|
||||
|
||||
Declared BEFORE the `/{asset_id}` route on purpose — FastAPI matches in
|
||||
declaration order, and "names" would otherwise be read as an asset id.
|
||||
"""
|
||||
q = _apply_asset_filters(db.query(Asset.id, Asset.hostname), db,
|
||||
include_inactive=include_inactive)
|
||||
rows = q.order_by(func.lower(Asset.hostname)).all()
|
||||
return [{"id": r.id, "hostname": r.hostname} for r in rows]
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_assets_csv(
|
||||
status: Optional[AssetStatus] = Query(None),
|
||||
source: Optional[AssetSource] = Query(None),
|
||||
search: Optional[str] = Query(None, description="Suche in Hostname, IP"),
|
||||
include_inactive: bool = Query(False, description="Also include DECOMMISSIONED assets (INACTIVE are shown by default)"),
|
||||
include_containers: bool = Query(True, description="false hides container images (unless source=CONTAINER)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -658,7 +761,8 @@ def export_assets_csv(
|
||||
"""
|
||||
query = db.query(Asset)
|
||||
query = _apply_asset_filters(query, db, status=status, source=source,
|
||||
search=search, include_inactive=include_inactive)
|
||||
search=search, include_inactive=include_inactive,
|
||||
include_containers=include_containers)
|
||||
# Eager-load the related columns the CSV reads (policy / assignee / groups)
|
||||
# so a full export does not fall back to a lazy SELECT per row under
|
||||
# yield_per. selectinload is yield_per-safe (joinedload is not).
|
||||
@@ -695,6 +799,8 @@ def export_assets_csv(
|
||||
"Wazuh Agent ID", "Nessus Host UUID", "Intune Device ID",
|
||||
"Defender Machine ID", "AAD Device ID", "VMware UUID",
|
||||
"Created At", "Updated At",
|
||||
"Intune Serial", "IGEL Unit ID", "IGEL Serial",
|
||||
"Netdisco Device IP", "Netdisco Serial", "VMware Serial",
|
||||
])
|
||||
yield buf.getvalue()
|
||||
buf.seek(0); buf.truncate(0)
|
||||
@@ -735,6 +841,12 @@ def export_assets_csv(
|
||||
asset.vmware_uuid or "",
|
||||
_fmt_dt(asset.created_at),
|
||||
_fmt_dt(asset.updated_at),
|
||||
asset.intune_serial or "",
|
||||
asset.igel_unit_id or "",
|
||||
asset.igel_serial or "",
|
||||
asset.netdisco_device_ip or "",
|
||||
asset.netdisco_serial or "",
|
||||
asset.vmware_serial or "",
|
||||
])
|
||||
yield buf.getvalue()
|
||||
buf.seek(0); buf.truncate(0)
|
||||
@@ -748,7 +860,7 @@ def export_assets_csv(
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
async def get_asset_detail(
|
||||
def get_asset_detail(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
@@ -772,7 +884,7 @@ async def get_asset_detail(
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, response_model=AssetResponse)
|
||||
async def create_asset(
|
||||
def create_asset(
|
||||
asset_data: AssetCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -780,26 +892,12 @@ async def create_asset(
|
||||
"""
|
||||
Erstellt manuelles Asset (für Systeme ohne Wazuh-Agent)
|
||||
"""
|
||||
# Check for duplicate hostname
|
||||
existing_hostname = db.query(Asset).filter(
|
||||
Asset.hostname == asset_data.hostname
|
||||
).first()
|
||||
if existing_hostname:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Asset with this hostname already exists"
|
||||
)
|
||||
|
||||
# Check for duplicate IP (only if IP is provided)
|
||||
if asset_data.ip_address:
|
||||
existing_ip = db.query(Asset).filter(
|
||||
Asset.ip_address == asset_data.ip_address
|
||||
).first()
|
||||
if existing_ip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Asset with this IP address already exists"
|
||||
)
|
||||
# Kein Duplikat-Check auf Hostname oder IP-Adresse: beides sind keine
|
||||
# Identifikatoren. Ab Werk vergebene Namen ("RackPDU") stehen mehrfach im
|
||||
# Bestand; Gastnetze, VPN-Pools, VLAN-Segmente und recycelte DHCP-Leases
|
||||
# fuehren dieselbe Adresse mehrfach. Der Operator konnte die betroffenen
|
||||
# Systeme sonst gar nicht erst anlegen. Was ein Asset identifiziert, steht
|
||||
# in services/asset_matching.py.
|
||||
|
||||
# Erstelle Asset
|
||||
asset = Asset(
|
||||
@@ -829,7 +927,7 @@ async def create_asset(
|
||||
|
||||
|
||||
@router.put("/{asset_id}", response_model=AssetResponse)
|
||||
async def update_asset(
|
||||
def update_asset(
|
||||
asset_id: int,
|
||||
update_data: AssetUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -899,7 +997,7 @@ async def update_asset(
|
||||
|
||||
|
||||
@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_asset(
|
||||
def delete_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
@@ -957,7 +1055,7 @@ async def delete_asset(
|
||||
|
||||
|
||||
@router.patch("/{asset_id}/assign")
|
||||
async def assign_asset(
|
||||
def assign_asset(
|
||||
asset_id: int,
|
||||
assign_data: AssetAssignRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -998,7 +1096,7 @@ async def assign_asset(
|
||||
|
||||
|
||||
@router.get("/{asset_id}/vulnerabilities")
|
||||
async def get_asset_vulnerabilities(
|
||||
def get_asset_vulnerabilities(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
@@ -1030,6 +1128,7 @@ async def get_asset_vulnerabilities(
|
||||
import json
|
||||
from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
|
||||
from app.models.setting import Setting
|
||||
from app.services.asset_matching import pick_one
|
||||
|
||||
@router.post("/sync_wazuh", response_model=dict)
|
||||
def sync_wazuh_assets(
|
||||
@@ -1039,7 +1138,19 @@ def sync_wazuh_assets(
|
||||
"""
|
||||
Synchronizes assets from Wazuh Manager.
|
||||
fetch active agents -> update/create local assets.
|
||||
|
||||
Recorded in the sync-run ledger as the "assets" phase; when the
|
||||
scheduled scan calls this, the record merges into that run.
|
||||
"""
|
||||
from app.services.sync_run_service import record_sync_run
|
||||
with record_sync_run("wazuh") as run:
|
||||
run.stats.setdefault("phase", "assets")
|
||||
stats = _sync_wazuh_assets(db)
|
||||
run.stats.update(stats)
|
||||
return stats
|
||||
|
||||
|
||||
def _sync_wazuh_assets(db: Session) -> dict:
|
||||
# 1. Get Wazuh Config (transparently decrypted)
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
raw_wazuh = read_setting_value(db, "wazuh_config")
|
||||
@@ -1085,6 +1196,7 @@ def sync_wazuh_assets(
|
||||
# not the TCP connection's.
|
||||
from app.services.asset_lifecycle import (
|
||||
apply_status,
|
||||
created,
|
||||
parse_source_timestamp,
|
||||
status_for_last_seen,
|
||||
threshold_days,
|
||||
@@ -1103,6 +1215,9 @@ def sync_wazuh_assets(
|
||||
|
||||
stats["total_from_wazuh"] = len(agents)
|
||||
stats["never_connected_skipped"] = 0
|
||||
# Every agent id this run reports — the discriminator for the
|
||||
# hostname fallback below.
|
||||
live_agent_ids = {a.get("id") for a in agents if a.get("id")}
|
||||
|
||||
for agent in agents:
|
||||
agent_id = agent.get("id")
|
||||
@@ -1129,7 +1244,22 @@ def sync_wazuh_assets(
|
||||
|
||||
if not asset:
|
||||
# Check by hostname to avoid duplicates if manually added
|
||||
asset = db.query(Asset).filter(Asset.hostname == hostname).first()
|
||||
# …but only if the name is free. This stage is what keeps
|
||||
# a re-registered agent on its asset: authd's <force>
|
||||
# replaces an agent of the same name (default since 4.3),
|
||||
# the host comes back under a NEW id, and the name is the
|
||||
# only way back to its history. A replaced agent is gone
|
||||
# from the list, so its id is not live — while two agents
|
||||
# that are BOTH in this run are two hosts, and adopting
|
||||
# the first one's asset would move the second one's
|
||||
# findings onto it and take the id away (two APC PDUs, both
|
||||
# named "RackPDU" from the factory, is all it takes).
|
||||
# Several free same-named assets: the address decides, or
|
||||
# the agent gets its own (pick_one).
|
||||
free = [a for a in db.query(Asset)
|
||||
.filter(Asset.hostname == hostname).order_by(Asset.id)
|
||||
if not (a.wazuh_agent_id and a.wazuh_agent_id in live_agent_ids)]
|
||||
asset = pick_one(free, ip, hostname)
|
||||
|
||||
if asset:
|
||||
# Update
|
||||
@@ -1186,6 +1316,7 @@ def sync_wazuh_assets(
|
||||
new_asset.groups.append(default_group)
|
||||
|
||||
db.add(new_asset)
|
||||
created(db, new_asset, "Wazuh")
|
||||
stats["created"] += 1
|
||||
|
||||
# The manager nodes themselves. They run no agent — they
|
||||
@@ -1225,7 +1356,11 @@ def sync_wazuh_assets(
|
||||
seen_wazuh_agent_ids.add(nid)
|
||||
a = db.query(Asset).filter(Asset.wazuh_agent_id == nid).first()
|
||||
if not a:
|
||||
a = db.query(Asset).filter(Asset.hostname == node["name"]).first()
|
||||
# Never an agent's asset: node names ship as "node01",
|
||||
# and taking it overwrote a real agent's id.
|
||||
a = (db.query(Asset).filter(Asset.hostname == node["name"],
|
||||
Asset.wazuh_agent_id.is_(None))
|
||||
.order_by(Asset.id).first())
|
||||
label = f"Wazuh {node.get('type') or 'node'} node"
|
||||
if a:
|
||||
a.wazuh_agent_id = nid
|
||||
@@ -1238,13 +1373,15 @@ def sync_wazuh_assets(
|
||||
"Wazuh manager node reported by the cluster sync")
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
db.add(Asset(hostname=node["name"], ip_address=node.get("ip"),
|
||||
wazuh_agent_id=nid, operating_system=label,
|
||||
os_version=node.get("version"),
|
||||
source=AssetSource.WAZUH,
|
||||
status=AssetStatus.ACTIVE,
|
||||
last_seen=datetime.now(),
|
||||
last_seen_source="wazuh"))
|
||||
a = Asset(hostname=node["name"], ip_address=node.get("ip"),
|
||||
wazuh_agent_id=nid, operating_system=label,
|
||||
os_version=node.get("version"),
|
||||
source=AssetSource.WAZUH,
|
||||
status=AssetStatus.ACTIVE,
|
||||
last_seen=datetime.now(),
|
||||
last_seen_source="wazuh")
|
||||
db.add(a)
|
||||
created(db, a, "Wazuh")
|
||||
stats["created"] += 1
|
||||
# Flush inside the guard: the agent loop's commit comes
|
||||
# after this block, so anything rejected by the database here
|
||||
@@ -1276,7 +1413,9 @@ def sync_wazuh_assets(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Wazuh Sync Failed: {e}")
|
||||
raise HTTPException(status_code=500, detail="Wazuh sync failed. Check server logs for details.")
|
||||
# The real reason goes to the caller (editors only): "check the
|
||||
# server logs" left an operator with a failing Wazuh API for a day.
|
||||
raise HTTPException(status_code=502, detail=f"Wazuh sync failed: {e}")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ _SORT_COLUMNS = {
|
||||
|
||||
|
||||
@router.get("/logs", response_model=List[AuditLogResponse])
|
||||
async def get_audit_logs(
|
||||
def get_audit_logs(
|
||||
response: Response,
|
||||
skip: int = 0,
|
||||
limit: int = Query(100, le=1000, description="Max rows per page (cap 1000)"),
|
||||
|
||||
+22
-20
@@ -294,7 +294,7 @@ def _issue_forced_setup_challenge(user: User) -> JSONResponse:
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def login(
|
||||
def login(
|
||||
request: Request,
|
||||
credentials: LoginRequest,
|
||||
db: Session = Depends(get_db)
|
||||
@@ -321,6 +321,8 @@ async def login(
|
||||
result = orchestrator.authenticate_with_credentials(
|
||||
credentials.username, credentials.password,
|
||||
actor_ip=ip, actor_user_agent=ua,
|
||||
# Browsers always send Sec-Fetch-Mode; scripts don't (a claim, not proof).
|
||||
channel="Web-UI" if request.headers.get("sec-fetch-mode") else "API",
|
||||
)
|
||||
except AuthError as e:
|
||||
raise HTTPException(
|
||||
@@ -356,7 +358,7 @@ async def login(
|
||||
|
||||
@router.post("/mfa/verify", response_model=LoginResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def mfa_verify(
|
||||
def mfa_verify(
|
||||
request: Request,
|
||||
payload: MfaVerifyRequest,
|
||||
db: Session = Depends(get_db)
|
||||
@@ -405,7 +407,7 @@ async def mfa_verify(
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
async def list_auth_providers():
|
||||
def list_auth_providers():
|
||||
"""
|
||||
Public endpoint: lists enabled auth providers so the frontend can render
|
||||
the correct mix of login buttons (local form, LDAP shares the local form,
|
||||
@@ -436,7 +438,7 @@ async def list_auth_providers():
|
||||
|
||||
@router.post("/mfa/setup", response_model=MfaSetupResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def mfa_setup(
|
||||
def mfa_setup(
|
||||
request: Request,
|
||||
payload: MfaSetupRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -480,7 +482,7 @@ async def mfa_setup(
|
||||
|
||||
@router.post("/mfa/activate")
|
||||
@limiter.limit("10/minute")
|
||||
async def mfa_activate(
|
||||
def mfa_activate(
|
||||
request: Request,
|
||||
payload: MfaActivateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -516,7 +518,7 @@ async def mfa_activate(
|
||||
|
||||
@router.post("/mfa/disable")
|
||||
@limiter.limit("5/minute")
|
||||
async def mfa_disable(
|
||||
def mfa_disable(
|
||||
request: Request,
|
||||
payload: MfaSetupRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -564,7 +566,7 @@ def _decode_setup_token(setup_token: str, db: Session) -> User:
|
||||
|
||||
@router.post("/mfa/forced-setup/start", response_model=MfaSetupResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def mfa_forced_setup_start(
|
||||
def mfa_forced_setup_start(
|
||||
request: Request,
|
||||
payload: MfaForcedSetupStartRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -599,7 +601,7 @@ async def mfa_forced_setup_start(
|
||||
|
||||
@router.post("/mfa/forced-setup/activate", response_model=LoginResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def mfa_forced_setup_activate(
|
||||
def mfa_forced_setup_activate(
|
||||
request: Request,
|
||||
payload: MfaForcedSetupActivateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -640,7 +642,7 @@ async def mfa_forced_setup_activate(
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
def logout(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
@@ -687,7 +689,7 @@ async def logout(
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=LoginResponse)
|
||||
async def refresh_token(
|
||||
def refresh_token(
|
||||
request: Request,
|
||||
refresh_request: Optional[RefreshTokenRequest] = None,
|
||||
db: Session = Depends(get_db)
|
||||
@@ -762,7 +764,7 @@ async def refresh_token(
|
||||
|
||||
@router.post("/setup-admin")
|
||||
@limiter.limit("3/minute")
|
||||
async def setup_admin(
|
||||
def setup_admin(
|
||||
request: Request,
|
||||
setup: SetupAdminRequest,
|
||||
db: Session = Depends(get_db)
|
||||
@@ -829,7 +831,7 @@ async def setup_admin(
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
"""
|
||||
Returns information about current user
|
||||
"""
|
||||
@@ -847,7 +849,7 @@ async def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register_user(
|
||||
def register_user(
|
||||
request: Request,
|
||||
user_data: RegisterRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -914,7 +916,7 @@ async def register_user(
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
def change_password(
|
||||
request: Request,
|
||||
password_data: PasswordChangeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -962,7 +964,7 @@ class AdminResetPasswordRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-mfa")
|
||||
async def admin_reset_mfa(
|
||||
def admin_reset_mfa(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
@@ -1001,7 +1003,7 @@ async def admin_reset_mfa(
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
async def admin_reset_password(
|
||||
def admin_reset_password(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
password_data: AdminResetPasswordRequest,
|
||||
@@ -1044,7 +1046,7 @@ async def admin_reset_password(
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
@@ -1071,7 +1073,7 @@ async def list_users(
|
||||
|
||||
@router.post("/users/{user_id}/unlock")
|
||||
@limiter.limit("10/minute")
|
||||
async def unlock_user(
|
||||
def unlock_user(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
@@ -1106,7 +1108,7 @@ class UserUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}")
|
||||
async def update_user(
|
||||
def update_user(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
user_data: UserUpdateRequest,
|
||||
@@ -1165,7 +1167,7 @@ async def update_user(
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
def delete_user(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
|
||||
@@ -107,7 +107,7 @@ def _audit(db: Session, user_id: int, event: AuditEventType, desc: str, request:
|
||||
# ============================================
|
||||
|
||||
@router.get("/status")
|
||||
async def auth_status(
|
||||
def auth_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
@@ -173,7 +173,7 @@ def _provider_is_configured(provider_name: str) -> bool:
|
||||
# ============================================
|
||||
|
||||
@router.get("/role-mappings")
|
||||
async def get_role_mappings(
|
||||
def get_role_mappings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
@@ -188,7 +188,7 @@ async def get_role_mappings(
|
||||
|
||||
|
||||
@router.put("/role-mappings")
|
||||
async def put_role_mappings(
|
||||
def put_role_mappings(
|
||||
payload: RoleMappingsPayload,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -229,7 +229,7 @@ async def put_role_mappings(
|
||||
# ============================================
|
||||
|
||||
@router.post("/ldap/test")
|
||||
async def test_ldap(
|
||||
def test_ldap(
|
||||
payload: LdapTestRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
@@ -278,7 +278,7 @@ async def test_ldap(
|
||||
|
||||
|
||||
@router.post("/oidc/test")
|
||||
async def test_oidc(
|
||||
def test_oidc(
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
"""Fetch OIDC discovery doc + JWKS, sanity-check fields."""
|
||||
@@ -314,7 +314,7 @@ async def test_oidc(
|
||||
|
||||
|
||||
@router.post("/saml/test")
|
||||
async def test_saml(
|
||||
def test_saml(
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
"""Fetch IdP metadata, return SSO URL + entity ID + cert thumbprint hint."""
|
||||
|
||||
@@ -157,7 +157,7 @@ def _validate_id_token(id_token: str, strategy: OidcStrategy, expected_nonce: st
|
||||
# ============================================
|
||||
|
||||
@router.get("/login")
|
||||
async def oidc_login(request: Request):
|
||||
def oidc_login(request: Request):
|
||||
"""
|
||||
SP-initiated login. Stash state+nonce+PKCE verifier in a signed cookie,
|
||||
then 302 to the IdP's authorize endpoint.
|
||||
|
||||
@@ -122,7 +122,7 @@ def _prepare_request(request: Request, form: dict) -> dict:
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def saml_login(request: Request):
|
||||
def saml_login(request: Request):
|
||||
"""SP-initiated: build AuthnRequest and redirect to IdP."""
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
@@ -230,7 +230,7 @@ async def saml_acs(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/metadata")
|
||||
async def saml_metadata():
|
||||
def saml_metadata():
|
||||
"""Returns SP metadata XML for the IdP administrator."""
|
||||
from onelogin.saml2.settings import OneLogin_Saml2_Settings
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Dockhand integration HTTP endpoints.
|
||||
|
||||
- POST /api/v1/integrations/dockhand/test token/connectivity probe
|
||||
- POST /api/v1/integrations/dockhand/sync trigger findings sync
|
||||
- GET /api/v1/integrations/dockhand/sync/status poll target for the GUI
|
||||
|
||||
Config lives in the settings table under `dockhand_config` (encrypted JSON:
|
||||
base_url, token, verify_ssl, min_severity, only_fixed, scan_nightly).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.dependencies import RequireAdmin, RequireEditor
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.models.user import User
|
||||
from app.services import dockhand_service
|
||||
from app.services.sync_run_service import record_sync_run
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations/dockhand", tags=["Dockhand"])
|
||||
|
||||
# ponytail: module-level state assumes the single uvicorn worker we ship with,
|
||||
# same as the Netdisco router; move to the DB if --workers is ever added.
|
||||
_DH_SYNC: dict = {"running": False, "result": None, "error": None,
|
||||
"started_at": None, "finished_at": None}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
def test_dockhand(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
cfg = dockhand_service.load_dockhand_config(db)
|
||||
if not cfg:
|
||||
raise HTTPException(400, "Dockhand is not configured (URL missing).")
|
||||
return dockhand_service.test_connection(cfg)
|
||||
|
||||
|
||||
def _run_dockhand_sync_threaded() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
with record_sync_run("dockhand", "manual") as run:
|
||||
stats = dockhand_service.run_dockhand_sync(db)
|
||||
run.stats.update(stats)
|
||||
_DH_SYNC["result"] = stats
|
||||
_DH_SYNC["error"] = None
|
||||
except Exception as e:
|
||||
_DH_SYNC["result"] = None
|
||||
_DH_SYNC["error"] = str(e)
|
||||
logger.error("Dockhand sync (manual) failed: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
_DH_SYNC["running"] = False
|
||||
_DH_SYNC["finished_at"] = time.time()
|
||||
|
||||
|
||||
@router.post("/sync", status_code=202)
|
||||
def sync_dockhand(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Kick off a Dockhand sync in the background (202). Poll GET /sync/status."""
|
||||
if not dockhand_service.load_dockhand_config(db):
|
||||
raise HTTPException(400, "Dockhand is not configured.")
|
||||
if _DH_SYNC["running"]:
|
||||
return {"status": "already_running", "detail": "A Dockhand sync is already in progress."}
|
||||
_DH_SYNC.update({"running": True, "result": None, "error": None,
|
||||
"started_at": time.time(), "finished_at": None})
|
||||
threading.Thread(target=_run_dockhand_sync_threaded, daemon=True).start()
|
||||
return {"status": "started", "detail": "Dockhand sync started in the background."}
|
||||
|
||||
|
||||
@router.get("/sync/status")
|
||||
def sync_dockhand_status(current_user: User = Depends(RequireEditor)):
|
||||
s = _DH_SYNC
|
||||
state = ("running" if s["running"] else "error" if s["error"]
|
||||
else "done" if s["result"] else "idle")
|
||||
return {"state": state, "running": s["running"], "result": s["result"],
|
||||
"error": s["error"], "finished_at": s["finished_at"]}
|
||||
+13
-9
@@ -8,7 +8,7 @@ from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.group import Group, user_groups
|
||||
from app.models.setting import Setting
|
||||
from app.auth.dependencies import get_current_user, RequireAdmin
|
||||
@@ -41,12 +41,14 @@ class GroupResponse(BaseModel):
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("", response_model=List[GroupResponse])
|
||||
async def list_groups(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
def list_groups(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
groups = db.query(Group).all()
|
||||
results = []
|
||||
# Every role needs the group list (asset / vulnerability filters), but the
|
||||
# membership is user data — /auth/users is admin-only, so this is too.
|
||||
is_admin = current_user.role == UserRole.ADMIN
|
||||
for group in groups:
|
||||
# Fetch members manually or via relationship
|
||||
members = [{"id": u.id, "username": u.username} for u in group.users]
|
||||
members = [{"id": u.id, "username": u.username} for u in group.users] if is_admin else []
|
||||
results.append({
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
@@ -58,7 +60,7 @@ async def list_groups(db: Session = Depends(get_db), current_user: User = Depend
|
||||
return results
|
||||
|
||||
@router.post("", response_model=GroupResponse)
|
||||
async def create_group(
|
||||
def create_group(
|
||||
group_in: GroupCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
@@ -93,7 +95,7 @@ async def create_group(
|
||||
}
|
||||
|
||||
@router.put("/{group_id}", response_model=GroupResponse)
|
||||
async def update_group(
|
||||
def update_group(
|
||||
group_id: int,
|
||||
group_in: GroupUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -130,7 +132,7 @@ async def update_group(
|
||||
}
|
||||
|
||||
@router.delete("/{group_id}")
|
||||
async def delete_group(
|
||||
def delete_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
@@ -155,14 +157,16 @@ async def delete_group(
|
||||
# --- Settings ---
|
||||
|
||||
@router.get("/default", response_model=Optional[int])
|
||||
async def get_default_group(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
def get_default_group(db: Session = Depends(get_db), current_user: User = Depends(RequireAdmin)):
|
||||
# Only the Groups page asks — and that page is admin-only. Its 403 is what
|
||||
# bounces a non-admin who typed the URL.
|
||||
setting = db.query(Setting).filter(Setting.key == "default_group_id").first()
|
||||
if setting and setting.value:
|
||||
return int(setting.value)
|
||||
return None
|
||||
|
||||
@router.post("/default")
|
||||
async def set_default_group(
|
||||
def set_default_group(
|
||||
group_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
|
||||
+7
-4
@@ -17,6 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.dependencies import RequireAdmin, RequireEditor
|
||||
from app.services.sync_run_service import record_sync_run
|
||||
from app.database import get_db, SessionLocal
|
||||
from app.models.user import User
|
||||
from app.services.igel_service import (
|
||||
@@ -35,7 +36,7 @@ _IGEL_SYNC: dict = {"running": False, "result": None, "error": None,
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_igel(
|
||||
def test_igel(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
@@ -61,7 +62,9 @@ async def test_igel(
|
||||
def _run_igel_sync_threaded() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = run_igel_sync(db)
|
||||
with record_sync_run("igel", "manual") as run:
|
||||
stats = run_igel_sync(db)
|
||||
run.stats.update(stats)
|
||||
result = {k: v for k, v in stats.items() if k != "errors"}
|
||||
_IGEL_SYNC["result"] = result
|
||||
_IGEL_SYNC["error"] = None
|
||||
@@ -77,7 +80,7 @@ def _run_igel_sync_threaded() -> None:
|
||||
|
||||
|
||||
@router.post("/sync", status_code=202)
|
||||
async def sync_igel(
|
||||
def sync_igel(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
@@ -97,7 +100,7 @@ async def sync_igel(
|
||||
|
||||
|
||||
@router.get("/sync/status")
|
||||
async def sync_igel_status(current_user: User = Depends(RequireEditor)):
|
||||
def sync_igel_status(current_user: User = Depends(RequireEditor)):
|
||||
"""Poll target for the GUI: current/last manual-sync state + result stats."""
|
||||
s = _IGEL_SYNC
|
||||
state = ("running" if s["running"]
|
||||
|
||||
@@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.dependencies import RequireAdmin, RequireEditor
|
||||
from app.services.sync_run_service import record_sync_run
|
||||
from app.database import get_db, SessionLocal
|
||||
from app.models.user import User
|
||||
from app.services.intune_service import load_intune_config, run_intune_sync, _build_client
|
||||
@@ -35,7 +36,7 @@ _INTUNE_SYNC: dict = {"running": False, "result": None, "error": None,
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_intune(
|
||||
def test_intune(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
@@ -53,7 +54,9 @@ async def test_intune(
|
||||
def _run_intune_sync_threaded() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = run_intune_sync(db)
|
||||
with record_sync_run("intune", "manual") as run:
|
||||
stats = run_intune_sync(db)
|
||||
run.stats.update(stats)
|
||||
result = {k: v for k, v in stats.items() if k != "errors"}
|
||||
_INTUNE_SYNC["result"] = result
|
||||
_INTUNE_SYNC["error"] = None
|
||||
@@ -69,7 +72,7 @@ def _run_intune_sync_threaded() -> None:
|
||||
|
||||
|
||||
@router.post("/sync", status_code=202)
|
||||
async def sync_intune(
|
||||
def sync_intune(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
@@ -88,7 +91,7 @@ async def sync_intune(
|
||||
|
||||
|
||||
@router.get("/sync/status")
|
||||
async def sync_intune_status(current_user: User = Depends(RequireEditor)):
|
||||
def sync_intune_status(current_user: User = Depends(RequireEditor)):
|
||||
"""Poll target for the GUI: current/last manual-sync state + result stats."""
|
||||
s = _INTUNE_SYNC
|
||||
state = ("running" if s["running"]
|
||||
|
||||
@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.dependencies import RequireAdmin, RequireEditor
|
||||
from app.services.sync_run_service import record_sync_run
|
||||
from app.database import get_db
|
||||
from app.integrations.nessus_client import (
|
||||
NessusAPIError,
|
||||
@@ -152,7 +153,9 @@ def trigger_nessus_sync(
|
||||
"""
|
||||
import httpx as _httpx
|
||||
try:
|
||||
stats = run_nessus_sync(db, scan_ids=payload.scan_ids)
|
||||
with record_sync_run("nessus", "manual") as run:
|
||||
stats = run_nessus_sync(db, scan_ids=payload.scan_ids)
|
||||
run.stats.update(stats)
|
||||
return {"message": "Nessus sync completed", **stats}
|
||||
except RuntimeError as e:
|
||||
# config missing
|
||||
@@ -287,7 +290,7 @@ def nessus_scan_host(
|
||||
|
||||
|
||||
@router.post("/scan-and-import")
|
||||
async def nessus_scan_and_import(
|
||||
def nessus_scan_and_import(
|
||||
payload: NessusScanImportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
@@ -311,7 +314,7 @@ async def nessus_scan_and_import(
|
||||
|
||||
|
||||
@router.get("/scan-and-import/status/{job_id}")
|
||||
async def nessus_scan_and_import_status(
|
||||
def nessus_scan_and_import_status(
|
||||
job_id: str,
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
@@ -324,7 +327,7 @@ async def nessus_scan_and_import_status(
|
||||
|
||||
|
||||
@router.post("/cleanup-pseudo-cves")
|
||||
async def cleanup_pseudo_cves(
|
||||
def cleanup_pseudo_cves(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Netdisco integration HTTP endpoints.
|
||||
|
||||
- POST /api/v1/integrations/netdisco/test auth/connectivity probe
|
||||
- POST /api/v1/integrations/netdisco/sync trigger inventory sync
|
||||
- GET /api/v1/integrations/netdisco/sync/status poll target for the GUI
|
||||
|
||||
Config lives in the settings table under `netdisco_config` (encrypted JSON:
|
||||
host, port, use_https, verify_ssl, api_key, username, password,
|
||||
auto_create_assets).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.dependencies import RequireAdmin, RequireEditor
|
||||
from app.services.sync_run_service import record_sync_run
|
||||
from app.database import get_db, SessionLocal
|
||||
from app.models.user import User
|
||||
from app.services.netdisco_service import (
|
||||
_build_client, load_netdisco_config, run_netdisco_sync,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations/netdisco", tags=["Netdisco"])
|
||||
|
||||
# Last/current manual-sync state so the GUI can poll for completion (the sync
|
||||
# itself is fire-and-forget 202). ponytail: module-level state assumes the
|
||||
# single uvicorn worker we ship with; if you add --workers, move this to the DB.
|
||||
_ND_SYNC: dict = {"running": False, "result": None, "error": None,
|
||||
"started_at": None, "finished_at": None}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
def test_netdisco(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
"""Verify the Netdisco connection: login (if any), then the inventory read."""
|
||||
cfg = load_netdisco_config(db)
|
||||
if not cfg:
|
||||
raise HTTPException(400, "Netdisco is not configured (host missing).")
|
||||
client = _build_client(cfg)
|
||||
try:
|
||||
return client.test_connection()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _run_netdisco_sync_threaded() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
with record_sync_run("netdisco", "manual") as run:
|
||||
stats = run_netdisco_sync(db)
|
||||
run.stats.update(stats)
|
||||
result = {k: v for k, v in stats.items() if k != "errors"}
|
||||
_ND_SYNC["result"] = result
|
||||
_ND_SYNC["error"] = None
|
||||
logger.info("Netdisco sync (manual) done: %s", result)
|
||||
except Exception as e:
|
||||
_ND_SYNC["result"] = None
|
||||
_ND_SYNC["error"] = str(e)
|
||||
logger.error("Netdisco sync (manual) failed: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
_ND_SYNC["running"] = False
|
||||
_ND_SYNC["finished_at"] = time.time()
|
||||
|
||||
|
||||
@router.post("/sync", status_code=202)
|
||||
def sync_netdisco(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Kick off a Netdisco inventory sync in the background (202).
|
||||
|
||||
Fire-and-forget so a large estate can't trip the reverse-proxy request
|
||||
timeout — a campus Netdisco holds thousands of devices. Poll GET
|
||||
/sync/status."""
|
||||
if not load_netdisco_config(db):
|
||||
raise HTTPException(400, "Netdisco is not configured.")
|
||||
if _ND_SYNC["running"]:
|
||||
return {"status": "already_running", "detail": "A Netdisco sync is already in progress."}
|
||||
_ND_SYNC.update({"running": True, "result": None, "error": None,
|
||||
"started_at": time.time(), "finished_at": None})
|
||||
threading.Thread(target=_run_netdisco_sync_threaded, daemon=True).start()
|
||||
return {"status": "started", "detail": "Netdisco sync started in the background."}
|
||||
|
||||
|
||||
@router.get("/sync/status")
|
||||
def sync_netdisco_status(current_user: User = Depends(RequireEditor)):
|
||||
"""Poll target for the GUI: current/last manual-sync state + result stats."""
|
||||
s = _ND_SYNC
|
||||
state = ("running" if s["running"]
|
||||
else "error" if s["error"]
|
||||
else "done" if s["result"]
|
||||
else "idle")
|
||||
return {"state": state, "running": s["running"], "result": s["result"],
|
||||
"error": s["error"], "finished_at": s["finished_at"]}
|
||||
@@ -5,7 +5,7 @@ Endpoints for notification log and email tests.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from pydantic import BaseModel
|
||||
@@ -47,7 +47,8 @@ class TestEmailRequest(BaseModel):
|
||||
|
||||
|
||||
@router.get("/log", response_model=List[NotificationLogResponse])
|
||||
async def list_notification_logs(
|
||||
def list_notification_logs(
|
||||
response: Response,
|
||||
notification_type: Optional[str] = Query(None),
|
||||
limit: int = Query(50, le=500),
|
||||
offset: int = Query(0),
|
||||
@@ -73,6 +74,8 @@ async def list_notification_logs(
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Matched rows before paging, so the GUI can show "Page x / y".
|
||||
response.headers["X-Total-Count"] = str(query.count())
|
||||
logs = query.offset(offset).limit(limit).all()
|
||||
|
||||
results = []
|
||||
|
||||
@@ -50,18 +50,19 @@ class PolicyResponse(PolicyBase):
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("", response_model=List[PolicyResponse])
|
||||
async def list_policies(
|
||||
def list_policies(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(RequireEditor)
|
||||
):
|
||||
"""List all policies"""
|
||||
"""List all policies (editor+ — the Policies page is, and readonly has no
|
||||
policy dropdown anywhere)."""
|
||||
policies = db.query(Policy).all()
|
||||
# TODO: Calculate real compliance per policy
|
||||
# For now, return mock or simple count (mocking generic 100% for start)
|
||||
return policies
|
||||
|
||||
@router.post("", response_model=PolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_policy(
|
||||
def create_policy(
|
||||
policy_data: PolicyCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
@@ -74,7 +75,7 @@ async def create_policy(
|
||||
return policy
|
||||
|
||||
@router.put("/{policy_id}", response_model=PolicyResponse)
|
||||
async def update_policy(
|
||||
def update_policy(
|
||||
policy_id: int,
|
||||
policy_data: PolicyUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -93,7 +94,7 @@ async def update_policy(
|
||||
return policy
|
||||
|
||||
@router.delete("/{policy_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_policy(
|
||||
def delete_policy(
|
||||
policy_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.services.report_scope import scoped as _scoped
|
||||
|
||||
|
||||
@router.get("/vulnerabilities/csv")
|
||||
async def export_vulnerabilities_csv(
|
||||
def export_vulnerabilities_csv(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
@@ -72,7 +72,7 @@ async def export_vulnerabilities_csv(
|
||||
|
||||
|
||||
@router.get("/executive-summary/pdf")
|
||||
async def export_executive_summary_pdf(
|
||||
def export_executive_summary_pdf(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
@@ -178,7 +178,7 @@ async def export_executive_summary_pdf(
|
||||
|
||||
|
||||
@router.get("/patching-progress/pdf")
|
||||
async def export_patching_progress_pdf(
|
||||
def export_patching_progress_pdf(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
@@ -249,7 +249,7 @@ async def export_patching_progress_pdf(
|
||||
|
||||
|
||||
@router.get("/compliance/iso27001/pdf")
|
||||
async def export_compliance_audit_pdf(
|
||||
def export_compliance_audit_pdf(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
|
||||
+57
-12
@@ -58,12 +58,12 @@ class ScheduleResponse(BaseModel):
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("", response_model=List[ScanResponse])
|
||||
async def list_scans(
|
||||
def list_scans(
|
||||
status: Optional[ScanStatus] = Query(None),
|
||||
asset_id: Optional[int] = Query(None),
|
||||
limit: int = Query(50),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(RequireEditor)
|
||||
):
|
||||
"""List scans with optional filtering"""
|
||||
query = db.query(Scan)
|
||||
@@ -123,10 +123,10 @@ class ScanRunSummary(BaseModel):
|
||||
|
||||
|
||||
@router.get("/summary", response_model=List[ScanRunSummary])
|
||||
async def list_scan_summaries(
|
||||
def list_scan_summaries(
|
||||
limit: int = Query(20),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(RequireEditor)
|
||||
):
|
||||
"""
|
||||
Returns scan runs grouped by time window (scans triggered within 5 seconds = one run).
|
||||
@@ -199,7 +199,7 @@ class ScanDeleteRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/clear", response_model=dict)
|
||||
async def clear_scans(
|
||||
def clear_scans(
|
||||
body: ScanDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -235,7 +235,26 @@ def trigger_autoscan(
|
||||
):
|
||||
"""
|
||||
Trigger automated scans for ALL assets connected to Wazuh.
|
||||
Recorded in the sync-run ledger; a run where every agent failed
|
||||
(authentication refused) counts as failed, not as 0 triggered.
|
||||
"""
|
||||
from app.services.sync_run_service import record_sync_run, fail_if_source_broken
|
||||
with record_sync_run("wazuh") as run:
|
||||
run.stats["phase"] = "vulnerabilities"
|
||||
result = _autoscan(db)
|
||||
run.stats.update({"agents_synced": result.get("scans_triggered", 0),
|
||||
"errors": result.get("errors", [])})
|
||||
# Every agent failed, or one of them failed the way a dead API fails:
|
||||
# a failed run, not "completed with errors".
|
||||
fail_if_source_broken(run, result.get("scans_triggered", 0), result.get("errors", []))
|
||||
# Every agent answered without a single CVE — the indexer is down or
|
||||
# empty even though the manager API let us in.
|
||||
if run.error is None and result.get("outage"):
|
||||
run.fail(f"Wazuh {result['outage']}")
|
||||
return result
|
||||
|
||||
|
||||
def _autoscan(db: Session) -> dict:
|
||||
# 1. Get Wazuh Configuration from DB (transparently decrypted)
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
raw_wazuh = read_setting_value(db, "wazuh_config")
|
||||
@@ -318,12 +337,13 @@ def trigger_autoscan(
|
||||
scan_obj.error_message = f"Wazuh connection failed: {str(e)}"
|
||||
scan_obj.completed_at = datetime.now()
|
||||
db.commit()
|
||||
raise HTTPException(status_code=500, detail="Failed to connect to Wazuh. Check server logs for details.")
|
||||
raise HTTPException(status_code=502, detail=f"Failed to connect to Wazuh: {e}")
|
||||
|
||||
return {
|
||||
"message": f"Triggered scans for {triggered_count} assets.",
|
||||
"scans_triggered": triggered_count,
|
||||
"errors": errors
|
||||
"errors": errors,
|
||||
**({"outage": run_stats["outage"]} if run_stats.get("outage") else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -338,7 +358,7 @@ INTERVAL_DELTA_MAP = {
|
||||
}
|
||||
|
||||
@router.get("/schedules", response_model=List[ScheduleResponse])
|
||||
async def list_schedules(
|
||||
def list_schedules(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
@@ -348,7 +368,7 @@ async def list_schedules(
|
||||
|
||||
|
||||
@router.post("/schedules", response_model=ScheduleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_schedule(
|
||||
def create_schedule(
|
||||
data: ScheduleCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -370,7 +390,7 @@ async def create_schedule(
|
||||
|
||||
|
||||
@router.put("/schedules/{schedule_id}", response_model=ScheduleResponse)
|
||||
async def update_schedule(
|
||||
def update_schedule(
|
||||
schedule_id: int,
|
||||
data: ScheduleCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -400,7 +420,7 @@ async def update_schedule(
|
||||
|
||||
|
||||
@router.patch("/schedules/{schedule_id}", response_model=ScheduleResponse)
|
||||
async def toggle_schedule(
|
||||
def toggle_schedule(
|
||||
schedule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -424,7 +444,7 @@ async def toggle_schedule(
|
||||
|
||||
|
||||
@router.delete("/schedules/{schedule_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_schedule(
|
||||
def delete_schedule(
|
||||
schedule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -436,3 +456,28 @@ async def delete_schedule(
|
||||
|
||||
db.delete(schedule)
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- Sync run ledger (every source, manual and scheduled) ---
|
||||
|
||||
@router.get("/sync-runs")
|
||||
def list_sync_runs(
|
||||
limit: int = Query(30, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Latest sync executions across all sources, newest first."""
|
||||
from app.services.sync_run_service import list_runs
|
||||
return list_runs(db, limit)
|
||||
|
||||
|
||||
@router.get("/sync-health")
|
||||
def get_sync_health(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Per source: last run, last success, and a state the GUI can colour
|
||||
(ok / failed / stale / running / unconfigured) — plus the failure-mail
|
||||
settings so the panel can edit them in place."""
|
||||
from app.services.sync_run_service import health_payload
|
||||
return health_payload(db)
|
||||
|
||||
@@ -29,7 +29,7 @@ class SyslogTestRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/syslog/test")
|
||||
async def test_syslog(
|
||||
def test_syslog(
|
||||
payload: SyslogTestRequest,
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
@@ -106,7 +106,7 @@ class SettingUpdate(BaseModel):
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("", response_model=List[SettingResponse])
|
||||
async def list_settings(
|
||||
def list_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
):
|
||||
@@ -114,7 +114,7 @@ async def list_settings(
|
||||
return [_serialize(s) for s in db.query(Setting).all()]
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
async def get_setting(
|
||||
def get_setting(
|
||||
key: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin)
|
||||
@@ -126,7 +126,7 @@ async def get_setting(
|
||||
return _serialize(setting)
|
||||
|
||||
@router.put("/{key}", response_model=SettingResponse)
|
||||
async def update_setting(
|
||||
def update_setting(
|
||||
key: str,
|
||||
update_data: SettingUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.dependencies import RequireAdmin, RequireEditor
|
||||
from app.services.sync_run_service import record_sync_run
|
||||
from app.database import get_db, SessionLocal
|
||||
from app.models.user import User
|
||||
from app.services.vcenter_service import (
|
||||
@@ -36,7 +37,7 @@ _VCENTER_SYNC: dict = {"running": False, "result": None, "error": None,
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_vcenter(
|
||||
def test_vcenter(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
@@ -54,7 +55,9 @@ async def test_vcenter(
|
||||
def _run_vcenter_sync_threaded() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = run_vcenter_sync(db)
|
||||
with record_sync_run("vcenter", "manual") as run:
|
||||
stats = run_vcenter_sync(db)
|
||||
run.stats.update(stats)
|
||||
result = {k: v for k, v in stats.items() if k != "errors"}
|
||||
_VCENTER_SYNC["result"] = result
|
||||
_VCENTER_SYNC["error"] = None
|
||||
@@ -70,7 +73,7 @@ def _run_vcenter_sync_threaded() -> None:
|
||||
|
||||
|
||||
@router.post("/sync", status_code=202)
|
||||
async def sync_vcenter(
|
||||
def sync_vcenter(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
@@ -89,7 +92,7 @@ async def sync_vcenter(
|
||||
|
||||
|
||||
@router.get("/sync/status")
|
||||
async def sync_vcenter_status(current_user: User = Depends(RequireEditor)):
|
||||
def sync_vcenter_status(current_user: User = Depends(RequireEditor)):
|
||||
"""Poll target for the GUI: current/last manual-sync state + result stats."""
|
||||
s = _VCENTER_SYNC
|
||||
state = ("running" if s["running"]
|
||||
@@ -101,7 +104,7 @@ async def sync_vcenter_status(current_user: User = Depends(RequireEditor)):
|
||||
|
||||
|
||||
@router.post("/catalog/refresh")
|
||||
async def refresh_build_catalog(
|
||||
def refresh_build_catalog(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireAdmin),
|
||||
):
|
||||
|
||||
+247
-31
@@ -5,7 +5,10 @@ Endpoints for CVE management, prioritization, and AI analysis.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
|
||||
import csv
|
||||
import io
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status, Query, BackgroundTasks
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from sqlalchemy import desc, asc, func, and_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -19,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
|
||||
from app.models.asset import Asset, AssetStatus
|
||||
from app.models.asset import Asset, AssetStatus, ContainerPlacement
|
||||
from app.models.ai_analysis import AIAnalysis
|
||||
from app.models.audit_log import AuditLog, AuditEventType
|
||||
from app.auth.dependencies import get_current_user, RequireEditor
|
||||
@@ -117,6 +120,8 @@ class VulnerabilityResponse(BaseModel):
|
||||
deferred_until: Optional[datetime] = None
|
||||
defer_reason: Optional[str] = None
|
||||
notification_suppressed: bool = False
|
||||
# Docker host(s) of a container-image finding, as {id, hostname}.
|
||||
runs_on: List[dict] = []
|
||||
# Per-package detail (1:N child rows). Empty for pseudo-CVEs.
|
||||
packages: List["PackageInfo"] = []
|
||||
has_fix_any: bool = False # at least one package has a real fix
|
||||
@@ -150,6 +155,7 @@ class DeferRequest(BaseModel):
|
||||
class VulnerabilityDetailResponse(VulnerabilityResponse):
|
||||
description: Optional[str]
|
||||
cvss_vector: Optional[str]
|
||||
vendor_severity: Optional[dict] = None
|
||||
exploit_maturity: Optional[str]
|
||||
published_date: Optional[datetime]
|
||||
last_modified_date: Optional[datetime]
|
||||
@@ -443,6 +449,7 @@ def _build_vuln_response(vuln: Vulnerability) -> dict:
|
||||
# Fields for DetailResponse (will be None if not detail)
|
||||
"description": getattr(vuln, 'description', None),
|
||||
"cvss_vector": getattr(vuln, 'cvss_vector', None),
|
||||
"vendor_severity": vuln.vendor_severities or None,
|
||||
"exploit_maturity": getattr(vuln, 'exploit_maturity', None),
|
||||
"published_date": getattr(vuln, 'published_date', None),
|
||||
"last_modified_date": getattr(vuln, 'last_modified_date', None),
|
||||
@@ -461,7 +468,7 @@ def _build_vuln_response(vuln: Vulnerability) -> dict:
|
||||
# ============================================
|
||||
|
||||
@router.post("/bulk-update")
|
||||
async def bulk_update_vulnerabilities(
|
||||
def bulk_update_vulnerabilities(
|
||||
update_data: BulkVulnerabilityUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -544,7 +551,7 @@ async def bulk_update_vulnerabilities(
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_vulnerabilities(
|
||||
def list_vulnerabilities(
|
||||
severity: Optional[VulnerabilitySeverity] = Query(None, description="Filter nach Severity"),
|
||||
status: Optional[str] = Query(None, description="Filter nach Status (enum value or 'all')"),
|
||||
exploitable: Optional[bool] = Query(None, description="Nur exploitable CVEs"),
|
||||
@@ -649,7 +656,7 @@ async def list_vulnerabilities(
|
||||
# `wazuh_only` would be too brittle, use string contains.
|
||||
if source:
|
||||
src = source.strip().lower()
|
||||
if src in {"wazuh", "nessus", "manual", "app-scan", "defender", "intune"}:
|
||||
if src in {"wazuh", "nessus", "manual", "app-scan", "defender", "intune", "trivy", "grype", "dockhand"}:
|
||||
query = query.filter(Vulnerability.sources.contains(f'"{src}"'))
|
||||
|
||||
if finding_type == "mobile":
|
||||
@@ -801,6 +808,7 @@ async def list_vulnerabilities(
|
||||
vulnerabilities = query.offset(offset).limit(limit).all()
|
||||
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
|
||||
_attach_last_change(db, vulnerabilities, items_resp)
|
||||
_attach_runs_on(db, vulnerabilities, items_resp)
|
||||
return {"items": items_resp, "total": total_count}
|
||||
|
||||
if sort_by == "cpr":
|
||||
@@ -821,6 +829,7 @@ async def list_vulnerabilities(
|
||||
vulnerabilities = query.offset(offset).limit(limit).all()
|
||||
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
|
||||
_attach_last_change(db, vulnerabilities, items_resp)
|
||||
_attach_runs_on(db, vulnerabilities, items_resp)
|
||||
return {"items": items_resp, "total": total_count}
|
||||
|
||||
if sort_by == "published_date":
|
||||
@@ -865,6 +874,7 @@ async def list_vulnerabilities(
|
||||
vulnerabilities = query.offset(offset).limit(limit).all()
|
||||
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
|
||||
_attach_last_change(db, vulnerabilities, items_resp)
|
||||
_attach_runs_on(db, vulnerabilities, items_resp)
|
||||
return {"items": items_resp, "total": total_count}
|
||||
|
||||
if sort_by == "cve_id":
|
||||
@@ -899,6 +909,7 @@ async def list_vulnerabilities(
|
||||
vulnerabilities = query.offset(offset).limit(limit).all()
|
||||
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
|
||||
_attach_last_change(db, vulnerabilities, items_resp)
|
||||
_attach_runs_on(db, vulnerabilities, items_resp)
|
||||
return {"items": items_resp, "total": total_count}
|
||||
|
||||
sort_column = sort_map.get(sort_by, Vulnerability.cvss_score)
|
||||
@@ -914,9 +925,29 @@ async def list_vulnerabilities(
|
||||
vulnerabilities = query.offset(offset).limit(limit).all()
|
||||
results = [_build_vuln_response(v) for v in vulnerabilities]
|
||||
_attach_last_change(db, vulnerabilities, results)
|
||||
_attach_runs_on(db, vulnerabilities, results)
|
||||
return {"items": results, "total": total_count}
|
||||
|
||||
|
||||
def _attach_runs_on(db: Session, vulns, results: list[dict]) -> None:
|
||||
"""Name the Docker host(s) of a container-image finding. Dockhand findings
|
||||
sit on the image asset, so the Host column showed the image only; the
|
||||
Dockhand sync links the image to its host asset (container_placements).
|
||||
One query for the whole page. No link (host name matched no asset, or
|
||||
several) → empty, the image name stays the only trace."""
|
||||
runs_on: dict[int, list[dict]] = {}
|
||||
ids = {v.asset_id for v in vulns}
|
||||
if ids:
|
||||
for image_id, host_id, host_name in (
|
||||
db.query(ContainerPlacement.image_asset_id, Asset.id, Asset.hostname)
|
||||
.join(Asset, Asset.id == ContainerPlacement.host_asset_id)
|
||||
.filter(ContainerPlacement.image_asset_id.in_(ids))
|
||||
.order_by(func.lower(Asset.hostname))):
|
||||
runs_on.setdefault(image_id, []).append({"id": host_id, "hostname": host_name})
|
||||
for r in results:
|
||||
r["runs_on"] = runs_on.get(r["asset_id"], [])
|
||||
|
||||
|
||||
def _attach_last_change(db: Session, vulns, results: list[dict]) -> None:
|
||||
"""Bulk-load latest VULNERABILITY_UPDATED audit row per vuln and
|
||||
glue a `last_change` dict onto each response item.
|
||||
@@ -983,6 +1014,51 @@ def _attach_last_change(db: Session, vulns, results: list[dict]) -> None:
|
||||
logger.warning("last_change preload failed: %s", e)
|
||||
|
||||
|
||||
@router.get("/patch-plan")
|
||||
def get_patch_plan(
|
||||
limit: Optional[int] = Query(None, ge=1, description="Top N groups; default: the whole plan"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Open findings grouped by the product to update, exploited first.
|
||||
|
||||
The whole plan by default — the GUI paginates it client-side, so a cap
|
||||
here would silently hide work rather than page through it.
|
||||
"""
|
||||
from app.services.patch_plan_service import build_patch_plan
|
||||
return build_patch_plan(db, limit=limit)
|
||||
|
||||
|
||||
@router.get("/patch-plan/export")
|
||||
def export_patch_plan_csv(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""The whole patch plan as CSV, in rank order. Lists are joined with " | "."""
|
||||
from app.services.patch_plan_service import build_patch_plan
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL)
|
||||
writer.writerow([
|
||||
"Rank", "Update", "Fixed In", "Hosts", "Findings", "CVEs",
|
||||
"Exploited CVE Count", "Exploited CVEs", "Priority Sum", "Priority Max",
|
||||
"Check Config CVE Count", "Check Config CVEs",
|
||||
])
|
||||
for rank, g in enumerate(build_patch_plan(db), 1):
|
||||
writer.writerow([
|
||||
rank, g["product"], " | ".join(g["fixed_versions"]),
|
||||
g["hosts"], g["findings"], g["cve_count"],
|
||||
len(g["exploited_cves"]), " | ".join(g["exploited_cves"]),
|
||||
g["priority_sum"], g["priority_max"],
|
||||
len(g["config_dependent_cves"]), " | ".join(g["config_dependent_cves"]),
|
||||
])
|
||||
fname = f"patch_plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return StreamingResponse(
|
||||
iter([buf.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ai-prioritization")
|
||||
def get_ai_prioritization(
|
||||
limit: int = Query(20, le=50),
|
||||
@@ -1022,7 +1098,7 @@ def get_ai_prioritization(
|
||||
|
||||
|
||||
@router.get("/ai-history")
|
||||
async def get_ai_history(
|
||||
def get_ai_history(
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
@@ -1050,7 +1126,7 @@ async def get_ai_history(
|
||||
|
||||
|
||||
@router.get("/reports/dashboard", response_model=DashboardStatsResponse)
|
||||
async def get_dashboard_statistics(
|
||||
def get_dashboard_statistics(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
@@ -1155,7 +1231,7 @@ async def get_dashboard_statistics(
|
||||
|
||||
|
||||
@router.get("/{vuln_id}", response_model=VulnerabilityDetailResponse)
|
||||
async def get_vulnerability_detail(
|
||||
def get_vulnerability_detail(
|
||||
vuln_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
@@ -1206,7 +1282,7 @@ async def get_vulnerability_detail(
|
||||
|
||||
|
||||
@router.patch("/{vuln_id}", response_model=VulnerabilityResponse)
|
||||
async def update_vulnerability_status(
|
||||
def update_vulnerability_status(
|
||||
vuln_id: int,
|
||||
update_data: VulnerabilityUpdateRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
@@ -1587,7 +1663,7 @@ def _run_date_backfill_threaded() -> dict:
|
||||
|
||||
|
||||
@router.post("/dates/backfill", status_code=202)
|
||||
async def backfill_cve_dates(
|
||||
def backfill_cve_dates(
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Kick off an on-demand CVE published/last-modified backfill.
|
||||
@@ -1626,7 +1702,7 @@ def refresh_kev_catalog(
|
||||
|
||||
|
||||
@router.patch("/{vuln_id}/assign")
|
||||
async def assign_vulnerability(
|
||||
def assign_vulnerability(
|
||||
vuln_id: int,
|
||||
assign_data: AssignRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -1656,7 +1732,7 @@ async def assign_vulnerability(
|
||||
|
||||
|
||||
@router.patch("/{vuln_id}/defer")
|
||||
async def defer_vulnerability(
|
||||
def defer_vulnerability(
|
||||
vuln_id: int,
|
||||
defer_data: DeferRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -1680,7 +1756,7 @@ async def defer_vulnerability(
|
||||
|
||||
|
||||
@router.patch("/{vuln_id}/undefer")
|
||||
async def undefer_vulnerability(
|
||||
def undefer_vulnerability(
|
||||
vuln_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -1703,7 +1779,7 @@ async def undefer_vulnerability(
|
||||
|
||||
|
||||
@router.patch("/{vuln_id}/suppress-notifications")
|
||||
async def toggle_notification_suppression(
|
||||
def toggle_notification_suppression(
|
||||
vuln_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor)
|
||||
@@ -1724,7 +1800,7 @@ class FalsePositiveRequest(BaseModel):
|
||||
|
||||
|
||||
@router.patch("/{vuln_id}/false-positive")
|
||||
async def mark_false_positive(
|
||||
def mark_false_positive(
|
||||
vuln_id: int,
|
||||
payload: FalsePositiveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -1752,7 +1828,7 @@ async def mark_false_positive(
|
||||
|
||||
|
||||
@router.patch("/{vuln_id}/unmark-false-positive")
|
||||
async def unmark_false_positive(
|
||||
def unmark_false_positive(
|
||||
vuln_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
@@ -1775,6 +1851,22 @@ async def unmark_false_positive(
|
||||
return _build_vuln_response(vuln)
|
||||
|
||||
|
||||
@router.post("/sync/container")
|
||||
def sync_container_image_report(
|
||||
report: dict = Body(..., description="Trivy `--format json` or Grype `-o json` output of ONE image"),
|
||||
min_severity: str = Query("high", description="none|low|medium|high|critical — below is not imported"),
|
||||
only_fixed: bool = Query(True, description="Import only CVEs with a published fix"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Import one container image scan. See container_scan_service."""
|
||||
from app.services.container_scan_service import ingest_report
|
||||
try:
|
||||
return ingest_report(db, report, min_severity=min_severity, only_fixed=only_fixed)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sync/wazuh")
|
||||
def sync_vulnerabilities_from_wazuh(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -1788,12 +1880,35 @@ def sync_vulnerabilities_from_wazuh(
|
||||
try:
|
||||
result = run_wazuh_vulnerability_sync(db)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise # already carries the real reason and status
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
"""Synchronous Wazuh vulnerability sync with statistics return"""
|
||||
"""Synchronous Wazuh vulnerability sync with statistics return.
|
||||
Recorded in the sync-run ledger as the "vulnerabilities" phase."""
|
||||
from app.services.sync_run_service import record_sync_run, fail_if_source_broken
|
||||
with record_sync_run("wazuh") as run:
|
||||
run.stats.setdefault("phase", "vulnerabilities")
|
||||
result = _run_wazuh_vulnerability_sync(db)
|
||||
run.stats.update({k: v for k, v in result.items() if k != "message"})
|
||||
# Only the INDEXER password was wrong once (2026-09-06): the manager
|
||||
# API answered, so nothing raised, every agent came back with 0 CVEs
|
||||
# and the run was COMPLETED — green health, green dialog, no mail.
|
||||
# A 401/5xx/connection error among the agents is the source being
|
||||
# down, and so is a run in which no agent returned anything.
|
||||
fail_if_source_broken(run, result.get("agents_synced", 0), result.get("errors", []))
|
||||
if run.error is None and result.get("outage"):
|
||||
run.fail(f"Wazuh {result['outage']}")
|
||||
if run.error:
|
||||
# The button must go red instead of "Synchronization Complete".
|
||||
raise HTTPException(status_code=502, detail=run.error)
|
||||
return result
|
||||
|
||||
|
||||
def _run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
|
||||
# 1. Get Wazuh Config from DB (transparently decrypted)
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
@@ -1836,6 +1951,9 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
# the pair that decides whether an empty answer is a clean host or an
|
||||
# unreachable API. Judged after the loop, not inside it.
|
||||
run_stats: dict = {}
|
||||
# Per-agent failures, handed to fail_if_source_broken() by the caller: a
|
||||
# 401 from the indexer is the source being down, not a host with no CVEs.
|
||||
errors: list[str] = []
|
||||
# IDs of vulns created during this sync — handed to the digest dispatcher
|
||||
# after the full sync completes (one summary email per recipient instead
|
||||
# of one email per CVE).
|
||||
@@ -1881,8 +1999,21 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
)
|
||||
db.add(scan_row)
|
||||
|
||||
# Get vulns from Wazuh
|
||||
wazuh_vulns = wazuh.get_vulnerabilities(agent_id)
|
||||
# Get vulns from Wazuh. An agent the indexer refused is neither
|
||||
# synced nor empty: it must not reach the backfill below, and it
|
||||
# must not be handed to reconcile_empty_agents() — that would
|
||||
# close every Wazuh finding on the host as soon as ONE other
|
||||
# agent answered, on the strength of a 401.
|
||||
try:
|
||||
wazuh_vulns = wazuh.get_vulnerabilities(agent_id)
|
||||
except Exception as e:
|
||||
logger.error("Wazuh sync: agent %s (%s) could not be read: %s",
|
||||
agent_id, asset.hostname, e)
|
||||
errors.append(f"{asset.hostname}: {e}")
|
||||
scan_row.status = ScanStatus.FAILED
|
||||
scan_row.completed_at = datetime.now()
|
||||
scan_row.error_message = str(e)[:500]
|
||||
continue
|
||||
stats["total_vulns_from_wazuh"] += len(wazuh_vulns)
|
||||
|
||||
# 1. Deduplicate Wazuh vulns by CVE ID for this asset
|
||||
@@ -2170,17 +2301,22 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
|
||||
f"Confirmed: {stats.get('vulns_confirmed', 0)}, "
|
||||
f"Patched: {stats['vulns_patched']}"
|
||||
),
|
||||
**stats
|
||||
**stats,
|
||||
"errors": errors,
|
||||
# Only present when reconcile_empty_agents() saw the outage
|
||||
# pattern: every agent answered without a single CVE. The caller
|
||||
# turns both this and `errors` into a failed run.
|
||||
**({"outage": run_stats["outage"]} if run_stats.get("outage") else {}),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
wazuh.close()
|
||||
logger.error(f"Wazuh sync failed: {e}")
|
||||
raise HTTPException(status_code=500, detail="Wazuh sync failed. Check server logs for details.")
|
||||
raise HTTPException(status_code=502, detail=f"Wazuh sync failed: {e}")
|
||||
|
||||
|
||||
async def sync_wazuh_vulnerabilities(db_session: Optional[Session] = None):
|
||||
def sync_wazuh_vulnerabilities(db_session: Optional[Session] = None):
|
||||
"""Background-Task for Wazuh-Sync (for Scheduler)"""
|
||||
from app.database import SessionLocal
|
||||
|
||||
@@ -2208,6 +2344,11 @@ def reconcile_empty_agents(db: Session, run_stats: dict) -> int:
|
||||
if not empty:
|
||||
return 0
|
||||
if not run_stats.get("cves_seen"):
|
||||
# Nothing was read and nothing was closed — that is a failed run, not
|
||||
# a quiet warning in the container log. The caller reads `outage` and
|
||||
# fails the run with it (see run_wazuh_vulnerability_sync).
|
||||
run_stats["outage"] = (f"every agent returned 0 CVEs ({len(empty)} agents) — "
|
||||
f"nothing was synced and nothing was closed")
|
||||
logger.warning("Wazuh sync: every agent returned 0 CVEs — treating as an "
|
||||
"outage, not closing anything (%d agents affected)", len(empty))
|
||||
return 0
|
||||
@@ -2593,7 +2734,7 @@ class OverrideStatsResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/override/check")
|
||||
async def check_incorrect_scores(
|
||||
def check_incorrect_scores(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
@@ -2773,7 +2914,7 @@ def override_from_vulnrichment(
|
||||
|
||||
|
||||
@router.post("/override/vulnrichment/start")
|
||||
async def start_vulnrichment_correction_job(
|
||||
def start_vulnrichment_correction_job(
|
||||
cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
|
||||
asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs (leer = alle)"),
|
||||
dry_run: bool = Query(False, description="Nur simulieren ohne DB-Änderungen"),
|
||||
@@ -2804,7 +2945,7 @@ async def start_vulnrichment_correction_job(
|
||||
|
||||
|
||||
@router.get("/override/vulnrichment/status/{job_id}")
|
||||
async def get_vulnrichment_correction_status(
|
||||
def get_vulnrichment_correction_status(
|
||||
job_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -2828,7 +2969,7 @@ async def get_vulnrichment_correction_status(
|
||||
|
||||
|
||||
@router.get("/override/vulnrichment/jobs")
|
||||
async def list_vulnrichment_correction_jobs(
|
||||
def list_vulnrichment_correction_jobs(
|
||||
limit: int = Query(10, le=50),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -3197,6 +3338,81 @@ def run_app_cve_scan_endpoint(
|
||||
raise HTTPException(502, f"App CVE scan failed: {e}")
|
||||
|
||||
|
||||
# Background MSRC scan job — the sync endpoint below stays for API clients,
|
||||
# but the GUI cannot use it: with rebuild_index the run re-pulls the monthly
|
||||
# CVRF documents first, which outlives the browser's HTTP patience exactly the
|
||||
# way the app scan did ("Backend connection failed" while the backend finished).
|
||||
_MSRC_SCAN_JOB: dict = {"running": False, "stage": None, "result": None,
|
||||
"error": None, "finished_at": None}
|
||||
|
||||
|
||||
def _msrc_scan_job_runner(asset_id: Optional[int], rebuild_index: bool):
|
||||
"""Run the MSRC fixed-build scan on its own DB session."""
|
||||
import time as _time
|
||||
from app.database import SessionLocal
|
||||
from app.services import msrc_scan_service
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if rebuild_index:
|
||||
_MSRC_SCAN_JOB["stage"] = "rebuilding the MSRC index…"
|
||||
msrc_scan_service.build_product_index(db)
|
||||
_MSRC_SCAN_JOB["stage"] = "scanning…"
|
||||
stats = msrc_scan_service.run_msrc_scan(db, asset_id=asset_id)
|
||||
_MSRC_SCAN_JOB["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
|
||||
_MSRC_SCAN_JOB["error"] = None
|
||||
_MSRC_SCAN_JOB["stage"] = "completed"
|
||||
except Exception as e:
|
||||
logger.exception("MSRC scan background job failed")
|
||||
_MSRC_SCAN_JOB["result"] = None
|
||||
_MSRC_SCAN_JOB["error"] = str(e)
|
||||
_MSRC_SCAN_JOB["stage"] = "failed"
|
||||
finally:
|
||||
db.close()
|
||||
_MSRC_SCAN_JOB["running"] = False
|
||||
_MSRC_SCAN_JOB["finished_at"] = _time.time()
|
||||
|
||||
|
||||
@router.post("/msrc-scan/start", status_code=202)
|
||||
def start_msrc_scan_job(
|
||||
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all Windows Server assets"),
|
||||
rebuild_index: bool = Query(True, description="Re-pull the MSRC CVRF documents first"),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
"""Start the MSRC fixed-build scan in the background; poll /msrc-scan/status.
|
||||
|
||||
`rebuild_index` defaults to True here (and to False on the sync endpoint):
|
||||
this is the button an operator presses after Microsoft published something,
|
||||
and the answer they want is what MSRC says NOW. The index it rebuilds is
|
||||
the same one the app scan's package pass reads, so the Edge/SharePoint
|
||||
findings of the next app scan come from this build too.
|
||||
"""
|
||||
import threading
|
||||
if _MSRC_SCAN_JOB["running"]:
|
||||
return {"started": False, "message": "An MSRC scan is already running",
|
||||
"poll_url": "/api/v1/vulnerabilities/msrc-scan/status"}
|
||||
_MSRC_SCAN_JOB.update({"running": True, "stage": "starting", "result": None,
|
||||
"error": None, "finished_at": None})
|
||||
threading.Thread(target=_msrc_scan_job_runner, args=(asset_id, rebuild_index),
|
||||
daemon=True).start()
|
||||
return {"started": True, "poll_url": "/api/v1/vulnerabilities/msrc-scan/status"}
|
||||
|
||||
|
||||
@router.get("/msrc-scan/status")
|
||||
def get_msrc_scan_status(current_user: User = Depends(get_current_user)):
|
||||
"""Snapshot of the background MSRC scan (running / result)."""
|
||||
s = _MSRC_SCAN_JOB
|
||||
return {
|
||||
"running": s["running"],
|
||||
"stage": s["stage"],
|
||||
"result": s["result"],
|
||||
"error": s["error"],
|
||||
"finished_at": s["finished_at"],
|
||||
"status": ("running" if s["running"]
|
||||
else "failed" if s["error"]
|
||||
else "completed" if s["result"] is not None else "idle"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/msrc-scan")
|
||||
# Sync def → worker threadpool; the CVRF fetch is blocking I/O.
|
||||
def run_msrc_scan_endpoint(
|
||||
@@ -3242,7 +3458,7 @@ def suppress_false_positives_endpoint(
|
||||
|
||||
|
||||
@router.get("/ai-remediation/status")
|
||||
async def ai_remediation_status(
|
||||
def ai_remediation_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -3490,7 +3706,7 @@ def _run_msrc_refresh_threaded(months_back: Optional[int] = None) -> None:
|
||||
|
||||
|
||||
@router.post("/msrc/refresh", status_code=202)
|
||||
async def refresh_msrc_endpoint(
|
||||
def refresh_msrc_endpoint(
|
||||
months_back: Optional[int] = Query(None, description="How many recent monthly MSRC docs to ingest"),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
@@ -3509,7 +3725,7 @@ async def refresh_msrc_endpoint(
|
||||
|
||||
|
||||
@router.get("/msrc/refresh/status")
|
||||
async def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
|
||||
def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
|
||||
"""Poll target for the GUI: current/last MSRC-refresh state + stats."""
|
||||
s = _MSRC_REFRESH
|
||||
state = ("running" if s["running"] else "error" if s["error"]
|
||||
@@ -3542,7 +3758,7 @@ def recompute_priority_scores(
|
||||
|
||||
|
||||
@router.post("/canonicalize-cve-metadata")
|
||||
async def canonicalize_cve_metadata(
|
||||
def canonicalize_cve_metadata(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(RequireEditor),
|
||||
):
|
||||
@@ -3604,7 +3820,7 @@ async def canonicalize_cve_metadata(
|
||||
|
||||
|
||||
@router.get("/override/stats")
|
||||
async def get_override_stats(
|
||||
def get_override_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
|
||||
+338
-117
@@ -29,6 +29,10 @@ from app.models.policy import Policy
|
||||
from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus
|
||||
from app.models.user import User
|
||||
from app.integrations.wazuh_client import WazuhClient
|
||||
from app.services.sync_run_service import (
|
||||
record_sync_run, mark_interrupted_runs, fail_if_source_broken,
|
||||
probe_wazuh_api, report_source_failure, source_failure,
|
||||
)
|
||||
|
||||
scheduler = AsyncIOScheduler() if HAS_APSCHEDULER else None
|
||||
|
||||
@@ -59,7 +63,9 @@ def execute_scheduled_scan(schedule_id: int):
|
||||
if scanner == "nessus":
|
||||
try:
|
||||
from app.services.nessus_sync import run_nessus_sync
|
||||
stats = run_nessus_sync(db)
|
||||
with record_sync_run("nessus", "scheduled") as run:
|
||||
stats = run_nessus_sync(db)
|
||||
run.stats.update(stats)
|
||||
logger.info(f"Scheduled Nessus sync done: {stats}")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduled Nessus sync failed: {e}")
|
||||
@@ -85,97 +91,120 @@ def execute_scheduled_scan(schedule_id: int):
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# ---- Wazuh branch (default, existing behaviour) ----
|
||||
# Wazuh Config laden (transparently decrypted)
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
raw_wazuh = read_setting_value(db, "wazuh_config")
|
||||
if not raw_wazuh:
|
||||
logger.error("Scheduled scan aborted: Wazuh configuration missing")
|
||||
return
|
||||
with record_sync_run("wazuh", "scheduled") as run:
|
||||
# ---- Wazuh branch (default, existing behaviour) ----
|
||||
# Wazuh Config laden (transparently decrypted)
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
raw_wazuh = read_setting_value(db, "wazuh_config")
|
||||
if not raw_wazuh:
|
||||
logger.error("Scheduled scan aborted: Wazuh configuration missing")
|
||||
run.fail("Wazuh configuration missing")
|
||||
return
|
||||
|
||||
config = json.loads(raw_wazuh)
|
||||
config = json.loads(raw_wazuh)
|
||||
|
||||
# Asset sync first — the same call the manual "Sync Data (Wazuh)"
|
||||
# button makes before it syncs vulnerabilities. Without it this job
|
||||
# only ever sees the agent ids already in the DB: a host that
|
||||
# re-registers with a NEW agent id keeps its stale id forever, so
|
||||
# every CVE and EOL lookup asks Wazuh about an agent that no longer
|
||||
# exists, and the asset never flips back from INACTIVE to ACTIVE
|
||||
# until someone clicks sync by hand.
|
||||
# ponytail: the endpoint function is called directly (current_user is
|
||||
# unused in its body) rather than extracted into a service — same
|
||||
# pattern as the sync_agent_vulnerabilities import below.
|
||||
try:
|
||||
from app.routers.assets import sync_wazuh_assets
|
||||
logger.info(f"Scheduled Wazuh asset sync: {sync_wazuh_assets(db=db, current_user=None)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduled Wazuh asset sync failed: {e}")
|
||||
# Asset sync first — the same call the manual "Sync Data (Wazuh)"
|
||||
# button makes before it syncs vulnerabilities. Without it this job
|
||||
# only ever sees the agent ids already in the DB: a host that
|
||||
# re-registers with a NEW agent id keeps its stale id forever, so
|
||||
# every CVE and EOL lookup asks Wazuh about an agent that no longer
|
||||
# exists, and the asset never flips back from INACTIVE to ACTIVE
|
||||
# until someone clicks sync by hand.
|
||||
# ponytail: the endpoint function is called directly (current_user is
|
||||
# unused in its body) rather than extracted into a service — same
|
||||
# pattern as the sync_agent_vulnerabilities import below.
|
||||
#
|
||||
# The asset sync logs in to the manager API (55000); the agent
|
||||
# loop below reads the indexer (9200). One can be dead while the
|
||||
# other answers — the API refused every login for a day while 64
|
||||
# agents "synced" fine each hour, and the run stayed COMPLETED
|
||||
# with the error folded into a list. A phase that could not log
|
||||
# in is a failed run, whatever the other phase did.
|
||||
phase_failures = []
|
||||
try:
|
||||
from app.routers.assets import sync_wazuh_assets
|
||||
logger.info(f"Scheduled Wazuh asset sync: {sync_wazuh_assets(db=db, current_user=None)}")
|
||||
except Exception as e:
|
||||
detail = getattr(e, "detail", None) or str(e)
|
||||
logger.error(f"Scheduled Wazuh asset sync failed: {detail}")
|
||||
run.stats.setdefault("errors", []).append(f"asset sync: {detail}")
|
||||
phase_failures.append(f"asset sync failed: {detail}")
|
||||
|
||||
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
|
||||
if not assets:
|
||||
logger.info("No Wazuh assets found for scheduled scan")
|
||||
schedule.last_run = datetime.now()
|
||||
db.commit()
|
||||
return
|
||||
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
|
||||
if not assets:
|
||||
logger.info("No Wazuh assets found for scheduled scan")
|
||||
schedule.last_run = datetime.now()
|
||||
db.commit()
|
||||
fail_if_source_broken(run, 0, [], phase_failures)
|
||||
return
|
||||
|
||||
triggered = 0
|
||||
errors = []
|
||||
triggered = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
with WazuhClient(
|
||||
base_url=config.get("api_url"),
|
||||
username=config.get("username"),
|
||||
password=config.get("password"),
|
||||
indexer_url=config.get("indexer_url"),
|
||||
indexer_username=config.get("indexer_username"),
|
||||
indexer_password=config.get("indexer_password"),
|
||||
verify_ssl=bool(config.get("verify_ssl", True))
|
||||
) as client:
|
||||
# Sync vulnerabilities for each agent
|
||||
from app.routers.vulnerabilities import (
|
||||
sync_agent_vulnerabilities, reconcile_empty_agents,
|
||||
)
|
||||
# Shared across the run so an agent that returns nothing can be
|
||||
# judged against the run as a whole (see reconcile_empty_agents).
|
||||
run_stats: dict = {}
|
||||
for asset in assets:
|
||||
try:
|
||||
with WazuhClient(
|
||||
base_url=config.get("api_url"),
|
||||
username=config.get("username"),
|
||||
password=config.get("password"),
|
||||
indexer_url=config.get("indexer_url"),
|
||||
indexer_username=config.get("indexer_username"),
|
||||
indexer_password=config.get("indexer_password"),
|
||||
verify_ssl=bool(config.get("verify_ssl", True))
|
||||
) as client:
|
||||
# Sync vulnerabilities for each agent
|
||||
from app.routers.vulnerabilities import (
|
||||
sync_agent_vulnerabilities, reconcile_empty_agents,
|
||||
)
|
||||
# Shared across the run so an agent that returns nothing can be
|
||||
# judged against the run as a whole (see reconcile_empty_agents).
|
||||
run_stats: dict = {}
|
||||
for asset in assets:
|
||||
try:
|
||||
scan = Scan(
|
||||
asset_id=asset.id,
|
||||
scan_type=ScanType.WAZUH, # source-labelled (was FULL)
|
||||
status=ScanStatus.RUNNING,
|
||||
started_at=datetime.now()
|
||||
)
|
||||
db.add(scan)
|
||||
|
||||
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
|
||||
asset, run_stats=run_stats)
|
||||
|
||||
scan.status = ScanStatus.COMPLETED
|
||||
scan.completed_at = datetime.now()
|
||||
triggered += 1
|
||||
except Exception as e:
|
||||
scan.status = ScanStatus.FAILED
|
||||
scan.error_message = str(e)
|
||||
errors.append(f"{asset.hostname}: {e}")
|
||||
logger.error(f"Scheduled scan error for {asset.hostname}: {e}")
|
||||
|
||||
# Now that the run is done, agents that returned nothing can be
|
||||
# judged: real emptiness if the API answered for anyone else.
|
||||
try:
|
||||
scan = Scan(
|
||||
asset_id=asset.id,
|
||||
scan_type=ScanType.WAZUH, # source-labelled (was FULL)
|
||||
status=ScanStatus.RUNNING,
|
||||
started_at=datetime.now()
|
||||
)
|
||||
db.add(scan)
|
||||
|
||||
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
|
||||
asset, run_stats=run_stats)
|
||||
|
||||
scan.status = ScanStatus.COMPLETED
|
||||
scan.completed_at = datetime.now()
|
||||
triggered += 1
|
||||
reconcile_empty_agents(db, run_stats)
|
||||
except Exception as e:
|
||||
scan.status = ScanStatus.FAILED
|
||||
scan.error_message = str(e)
|
||||
errors.append(f"{asset.hostname}: {e}")
|
||||
logger.error(f"Scheduled scan error for {asset.hostname}: {e}")
|
||||
logger.error(f"Wazuh empty-agent reconcile failed: {e}")
|
||||
# Every agent answered without a single CVE: the indexer
|
||||
# is down or empty, whatever the manager API said.
|
||||
if run_stats.get("outage"):
|
||||
phase_failures.append(f"vulnerability sync: {run_stats['outage']}")
|
||||
|
||||
# Now that the run is done, agents that returned nothing can be
|
||||
# judged: real emptiness if the API answered for anyone else.
|
||||
try:
|
||||
reconcile_empty_agents(db, run_stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Wazuh empty-agent reconcile failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Wazuh connection error during scheduled scan: {e}")
|
||||
run.fail(f"Wazuh connection failed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Wazuh connection error during scheduled scan: {e}")
|
||||
schedule.last_run = datetime.now()
|
||||
interval_delta = INTERVAL_MAP.get(schedule.interval, {"days": 1})
|
||||
schedule.next_run = datetime.now() + timedelta(**interval_delta)
|
||||
db.commit()
|
||||
|
||||
schedule.last_run = datetime.now()
|
||||
interval_delta = INTERVAL_MAP.get(schedule.interval, {"days": 1})
|
||||
schedule.next_run = datetime.now() + timedelta(**interval_delta)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Scheduled scan '{schedule.name}' completed: {triggered} assets scanned, {len(errors)} errors")
|
||||
logger.info(f"Scheduled scan '{schedule.name}' completed: {triggered} assets scanned, {len(errors)} errors")
|
||||
run.stats.update({"phase": "assets+vulnerabilities", "agents_synced": triggered,
|
||||
"errors": run.stats.get("errors", []) + errors})
|
||||
if run.error is None: # a connection failure above already named the cause
|
||||
fail_if_source_broken(run, triggered, errors, phase_failures)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduled scan error: {e}")
|
||||
@@ -483,6 +512,25 @@ def exploit_intel_nightly():
|
||||
db.close()
|
||||
|
||||
|
||||
def jev_triage_nightly():
|
||||
"""Jev config hint for CVEs that have none yet (jev_triage_service).
|
||||
|
||||
06:00 — after every ingest of the night (last one 05:40), so a CVE found
|
||||
tonight carries its hint when the patch plan is opened in the morning.
|
||||
No-op unless enabled; a hint only, it changes no finding and no score.
|
||||
"""
|
||||
from app.services.jev_triage_service import run_jev_triage
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
run_jev_triage(db)
|
||||
except Exception as e:
|
||||
logger.error("Jev triage nightly failed: %s", e)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def eol_check_nightly():
|
||||
"""Run endoflife.date EOL detection for every Wazuh-linked asset.
|
||||
|
||||
@@ -523,9 +571,14 @@ def eol_check_nightly():
|
||||
indexer_password=cfg.get("indexer_password"),
|
||||
verify_ssl=bool(cfg.get("verify_ssl", True)),
|
||||
)
|
||||
# A refused login is a Wazuh failure, recorded and mailed as one —
|
||||
# not 64 "package fetch failed" warnings and a job that "completed".
|
||||
if probe_wazuh_api(wazuh, phase="eol-check", trigger="scheduled"):
|
||||
return
|
||||
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
|
||||
upserts = 0
|
||||
closed = 0
|
||||
fetch_errors = []
|
||||
for asset in assets:
|
||||
# Every EOL finding this asset still justifies; what is missing at
|
||||
# the end of the pass has left the inventory. Same contract as the
|
||||
@@ -554,6 +607,7 @@ def eol_check_nightly():
|
||||
pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
|
||||
except Exception as e:
|
||||
logger.warning("EOL: package fetch failed for %s: %s", asset.hostname, e)
|
||||
fetch_errors.append(f"{asset.hostname}: {e}")
|
||||
continue
|
||||
# Same sweep the button runs — one implementation, so the nightly
|
||||
# result and the on-demand result cannot drift apart.
|
||||
@@ -563,7 +617,12 @@ def eol_check_nightly():
|
||||
closed += res["closed"]
|
||||
db.commit()
|
||||
logger.info("EOL nightly: %d assets scanned, %d EOL upserts, %d closed "
|
||||
"(no longer installed)", len(assets), upserts, closed)
|
||||
"(no longer installed), %d fetch errors",
|
||||
len(assets), upserts, closed, len(fetch_errors))
|
||||
# The API dying mid-run looks like per-asset noise; it is not.
|
||||
hit = source_failure(fetch_errors)
|
||||
if hit:
|
||||
report_source_failure("wazuh", "scheduled", "eol-check packages", hit)
|
||||
except Exception as e:
|
||||
logger.error("EOL nightly failed: %s", e)
|
||||
db.rollback()
|
||||
@@ -592,7 +651,9 @@ def intune_sync_nightly():
|
||||
m365_service.fetch_security_data(db, force_refresh=True)
|
||||
except Exception as e:
|
||||
logger.warning("M365 page refresh failed (non-fatal, cache kept): %s", e)
|
||||
stats = run_intune_sync(db)
|
||||
with record_sync_run("intune", "scheduled") as run:
|
||||
stats = run_intune_sync(db)
|
||||
run.stats.update(stats)
|
||||
logger.info("Intune nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.error("Intune nightly failed: %s", e)
|
||||
@@ -618,7 +679,9 @@ def vcenter_sync_nightly():
|
||||
if not load_vcenter_config(db):
|
||||
logger.info("vCenter sync skipped — vcenter_config not set")
|
||||
return
|
||||
stats = run_vcenter_sync(db, refresh_catalog=True)
|
||||
with record_sync_run("vcenter", "scheduled") as run:
|
||||
stats = run_vcenter_sync(db, refresh_catalog=True)
|
||||
run.stats.update(stats)
|
||||
logger.info("vCenter nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.error("vCenter nightly failed: %s", e)
|
||||
@@ -640,7 +703,9 @@ def igel_sync_nightly():
|
||||
if not load_igel_config(db):
|
||||
logger.info("IGEL sync skipped — igel_config not set")
|
||||
return
|
||||
stats = run_igel_sync(db)
|
||||
with record_sync_run("igel", "scheduled") as run:
|
||||
stats = run_igel_sync(db)
|
||||
run.stats.update(stats)
|
||||
logger.info("IGEL nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.error("IGEL nightly failed: %s", e)
|
||||
@@ -649,6 +714,56 @@ def igel_sync_nightly():
|
||||
db.close()
|
||||
|
||||
|
||||
def netdisco_sync_nightly():
|
||||
"""Sync Netdisco's device inventory → assets and firmware CVEs.
|
||||
|
||||
Skipped when netdisco_config is not set. Slotted at 02:40 UTC, right after
|
||||
the IGEL sync and before the app-CVE scan at 03:20 — the inventory has to
|
||||
exist before anything scans it, and the cvelistV5 index it reads was
|
||||
rebuilt at 01:30 (vuln_index_refresh_nightly).
|
||||
"""
|
||||
from app.services.netdisco_service import load_netdisco_config, run_netdisco_sync
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not load_netdisco_config(db):
|
||||
logger.info("Netdisco sync skipped — netdisco_config not set")
|
||||
return
|
||||
with record_sync_run("netdisco", "scheduled") as run:
|
||||
stats = run_netdisco_sync(db)
|
||||
run.stats.update(stats)
|
||||
logger.info("Netdisco nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.error("Netdisco nightly failed: %s", e)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def dockhand_sync_nightly():
|
||||
"""Pull Dockhand's cached container-image scan findings.
|
||||
|
||||
Skipped when dockhand_config is not set. 02:50 UTC, after the other
|
||||
inventory syncs. Reads nothing another job refreshes — with `scan_nightly`
|
||||
it has Dockhand rescan every image first (Dockhand has no scan schedule),
|
||||
and enrichment runs inline for new findings.
|
||||
"""
|
||||
from app.services.dockhand_service import load_dockhand_config, run_dockhand_sync
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not load_dockhand_config(db):
|
||||
logger.info("Dockhand sync skipped — dockhand_config not set")
|
||||
return
|
||||
with record_sync_run("dockhand", "scheduled") as run:
|
||||
stats = run_dockhand_sync(db, rescan=True)
|
||||
run.stats.update(stats)
|
||||
logger.info("Dockhand nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.error("Dockhand nightly failed: %s", e)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def m365_check_nightly():
|
||||
"""Detect Microsoft 365 Apps CVEs (Plan P) for every Wazuh-linked asset.
|
||||
|
||||
@@ -711,6 +826,89 @@ def m365_check_nightly():
|
||||
db.close()
|
||||
|
||||
|
||||
def vuln_index_refresh_nightly():
|
||||
"""Rebuild every vulnerability index a nightly sync decides from — BEFORE
|
||||
the first sync of the night, not inside the last scan.
|
||||
|
||||
01:30 UTC. The Intune sync at 02:10 runs the app-CVE scan for each of its
|
||||
devices (and the Defender TVM pull), the IGEL sync at 02:30 scans its
|
||||
endpoints and the UMS server, the vCenter sync at 02:20 its hosts, the
|
||||
Netdisco sync at 02:40 its switches — and
|
||||
every one of them reads the cvelistV5 index and the vendor indexes as
|
||||
stored. Those used to be rebuilt by the app-CVE job at 03:20, i.e. AFTER
|
||||
all three syncs, so an Intune-only device was matched against yesterday's
|
||||
catalogue every night (the 03:20 pass over all assets caught up, but only
|
||||
once it got there — hours later on a large estate). Now:
|
||||
|
||||
01:30 this job cvelistV5 (+MFSA), GitHub advisories,
|
||||
TeamViewer bulletins, IGEL ISNs, MSRC fixed builds
|
||||
02:10 Intune/Defender sync reads them fresh
|
||||
02:20 vCenter sync
|
||||
02:30 IGEL sync
|
||||
02:40 Netdisco sync
|
||||
03:20 app-CVE scan reads the same index; builds only if missing
|
||||
03:50 MSRC OS scan reuses the MSRC index from 01:30
|
||||
|
||||
Each rebuild is independent and a failed one keeps the cached index (see
|
||||
the build functions), so an offline vendor site costs that source a night,
|
||||
never the others and never the syncs.
|
||||
|
||||
ponytail: the 40-minute gap is the guarantee. The elapsed time is logged;
|
||||
if this job ever runs past 02:10, chain the Intune sync onto it instead of
|
||||
widening the gap.
|
||||
"""
|
||||
import time as _time
|
||||
from app.services import (cvelistv5_scan_service, github_repo_advisory_service,
|
||||
igel_isn_service, teamviewer_bulletin_service)
|
||||
db = SessionLocal()
|
||||
t0 = _time.monotonic()
|
||||
try:
|
||||
# force_fresh: the /tmp ZIP is shared with the threat-intel refresh,
|
||||
# which runs on an interval anchored to app startup — under the plain
|
||||
# 12h TTL this build kept reusing an afternoon snapshot and missed the
|
||||
# evening's CVEs for a full extra night (Chrome 151.0.7922.169).
|
||||
try:
|
||||
cvelistv5_scan_service.build_product_index(db, force_fresh=True)
|
||||
except Exception as e:
|
||||
logger.warning("cvelistV5 index build failed (non-fatal, cache kept): %s", e)
|
||||
# Vendor indexes: all cache 24h and used to be refreshed by whoever
|
||||
# asked first — the scan itself, one build-duration short of the TTL
|
||||
# every night, so they only ever cleared it every OTHER night (the
|
||||
# MFSA bug, 978d4f3). Notepad++, Wazuh and the IGEL ISNs without a
|
||||
# CVE reach no other source at all; TeamViewer publishes days before
|
||||
# NVD — a night late is a night blind.
|
||||
from app.services import citrix_workspace_service
|
||||
for _mod, _label in ((github_repo_advisory_service, "repo-advisory"),
|
||||
(teamviewer_bulletin_service, "teamviewer-bulletin"),
|
||||
(citrix_workspace_service, "citrix-build-catalog"),
|
||||
(igel_isn_service, "igel-isn")):
|
||||
try:
|
||||
_mod.build_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("%s index build failed (non-fatal, cache kept): %s",
|
||||
_label, e)
|
||||
# MSRC fixed builds — the one index b802f3a left where it was. The
|
||||
# 03:20 app scan's package pass creates the Edge findings from it, and
|
||||
# it was rebuilt by the MSRC job at 03:50, i.e. AFTER that scan, so a
|
||||
# Chromium CVE MSRC filed under Edge on day D reached the host on D+2
|
||||
# (CVE-2026-84324: published 03.09. 17:00, "app-scan only" on 04.09.).
|
||||
# Edge CVEs exist in no other source, so a night late is a night blind.
|
||||
# The 03:50 job reuses this build (ensure_index) instead of pulling
|
||||
# the 18 documents again; a failed build keeps the cached index.
|
||||
try:
|
||||
from app.services import msrc_scan_service
|
||||
msrc_scan_service.build_product_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("MSRC index build failed (non-fatal, cache kept): %s", e)
|
||||
logger.info("Vulnerability index refresh done in %.0fs — the 02:10 "
|
||||
"Intune/Defender sync reads these", _time.monotonic() - t0)
|
||||
except Exception as e:
|
||||
logger.error("Vulnerability index refresh failed: %s", e)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def app_cve_scan_nightly():
|
||||
"""Built-in app→CVE scanner for every asset with software inventory.
|
||||
|
||||
@@ -719,42 +917,19 @@ def app_cve_scan_nightly():
|
||||
closes the coverage gap for Intune-only / mobile devices that have no
|
||||
real scanner. Source 'app-scan'; cross-confirms with the other scanners.
|
||||
|
||||
Slotted at 03:25 UTC — after M365 (03:20), before the audit prune (03:30).
|
||||
Slotted at 03:20 UTC. The cvelistV5 and vendor indexes it decides from
|
||||
are rebuilt at 01:30 by vuln_index_refresh_nightly — before the Intune,
|
||||
vCenter, IGEL and Netdisco syncs that read them too; run_app_cve_scan builds the
|
||||
cvelistV5 index itself only when none is stored yet.
|
||||
Cache (TTL 7d) keeps OSV/NVD load bounded; NVD_API_KEY recommended.
|
||||
"""
|
||||
from app.services import (app_cve_scanner_service, cvelistv5_scan_service,
|
||||
github_repo_advisory_service,
|
||||
teamviewer_bulletin_service)
|
||||
from app.services import app_cve_scanner_service
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Rebuild the cvelistV5 reverse index first (one ~557 MB ZIP walk) so
|
||||
# the scan below has fresh product→CVE ranges for curated software.
|
||||
# force_fresh: the /tmp ZIP is shared with the threat-intel refresh,
|
||||
# which runs on an interval anchored to app startup — under the plain
|
||||
# 12h TTL this build kept reusing an afternoon snapshot and missed the
|
||||
# evening's CVEs for a full extra night (Chrome 151.0.7922.169).
|
||||
try:
|
||||
cvelistv5_scan_service.build_product_index(db, force_fresh=True)
|
||||
except Exception as e:
|
||||
logger.warning("cvelistV5 index build failed (non-fatal): %s", e)
|
||||
# Same for the two vendor indexes the scan below decides from. Both
|
||||
# cache 24h and used to be refreshed by whoever asked first — which is
|
||||
# the scan itself, one build-duration short of the TTL every night, so
|
||||
# they only ever cleared it every OTHER night (the MFSA bug, 978d4f3).
|
||||
# Notepad++ and Wazuh advisories reach no other source at all, and
|
||||
# TeamViewer publishes days before NVD, so a night late is a night
|
||||
# blind. A failed rebuild keeps the cached index (see build_index).
|
||||
for _mod, _label in ((github_repo_advisory_service, "repo-advisory"),
|
||||
(teamviewer_bulletin_service, "teamviewer-bulletin")):
|
||||
try:
|
||||
_mod.build_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("%s index build failed (non-fatal, cache kept): %s",
|
||||
_label, e)
|
||||
# FP-suppression is part of run_app_cve_scan itself now, so every way of
|
||||
# starting a scan — nightly, GUI, single asset — produces the same
|
||||
# result. It used to hang off this job alone.
|
||||
stats = app_cve_scanner_service.run_app_cve_scan(db)
|
||||
stats = app_cve_scanner_service.run_app_cve_scan(db, trigger="scheduled")
|
||||
logger.info("App CVE scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
except Exception as e:
|
||||
logger.error("App CVE scan nightly failed: %s", e)
|
||||
@@ -792,7 +967,10 @@ def msrc_scan_nightly():
|
||||
from app.services import msrc_scan_service, msrc_service
|
||||
db = SessionLocal()
|
||||
try:
|
||||
msrc_scan_service.build_product_index(db)
|
||||
# The index was rebuilt at 01:30 by vuln_index_refresh_nightly, before
|
||||
# the app scan that creates the Edge findings from it. Reuse that
|
||||
# build; rebuild only when it did not happen (or is a day old).
|
||||
msrc_scan_service.ensure_index(db, max_age=msrc_scan_service.SCAN_INDEX_MAX_AGE)
|
||||
stats = msrc_scan_service.run_msrc_scan(db)
|
||||
logger.info("MSRC scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
# Remediations for what this pass (and the app scan before it) just
|
||||
@@ -946,7 +1124,7 @@ def refresh_compliance_sca():
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = refresh_all_compliance(db)
|
||||
stats = refresh_all_compliance(db, trigger="scheduled")
|
||||
logger.info(
|
||||
"Compliance SCA refresh done: %d assets, %d policy results, %d errors",
|
||||
stats["assets_synced"], stats["policies_synced"], len(stats["errors"]),
|
||||
@@ -1268,6 +1446,17 @@ def start_scheduler():
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly Jev config hint at 06:00 — after the last ingest (05:40), so
|
||||
# tonight's new CVEs carry their hint on the patch plan. No-op unless
|
||||
# enabled; a hint only, changes no finding and no score.
|
||||
scheduler.add_job(
|
||||
jev_triage_nightly,
|
||||
trigger=CronTrigger(hour=6, minute=0),
|
||||
id="jev_triage_nightly",
|
||||
name="Nightly Jev Config Hint (off unless enabled)",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly exploit-intel refresh (Plan M) at 03:45 — pulls
|
||||
# Exploit-DB CSV + PoC-in-GitHub + Metasploit module index, writes
|
||||
# per-vuln counts + ref lists.
|
||||
@@ -1280,6 +1469,18 @@ def start_scheduler():
|
||||
)
|
||||
|
||||
# Nightly Microsoft Intune device/inventory sync (02:10 UTC).
|
||||
# Nightly vulnerability index refresh (01:30 UTC) — cvelistV5 (+MFSA),
|
||||
# GitHub advisories, TeamViewer bulletins, IGEL ISNs. First job of the
|
||||
# scan night on purpose: the Intune/Defender sync at 02:10 decides from
|
||||
# these, and it can run for hours on a large estate.
|
||||
scheduler.add_job(
|
||||
vuln_index_refresh_nightly,
|
||||
trigger=CronTrigger(hour=1, minute=30),
|
||||
id="vuln_index_refresh_nightly",
|
||||
name="Nightly Vulnerability Index Refresh",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
intune_sync_nightly,
|
||||
trigger=CronTrigger(hour=2, minute=10),
|
||||
@@ -1306,6 +1507,24 @@ def start_scheduler():
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly Netdisco switch/router inventory sync (02:40 UTC).
|
||||
scheduler.add_job(
|
||||
netdisco_sync_nightly,
|
||||
trigger=CronTrigger(hour=2, minute=40),
|
||||
id="netdisco_sync_nightly",
|
||||
name="Nightly Netdisco Device Inventory Sync",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly Dockhand container-image findings pull (02:50 UTC).
|
||||
scheduler.add_job(
|
||||
dockhand_sync_nightly,
|
||||
trigger=CronTrigger(hour=2, minute=50),
|
||||
id="dockhand_sync_nightly",
|
||||
name="Nightly Dockhand Container Image Findings Sync",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Nightly built-in app→CVE scan (03:20 UTC) — maps installed software
|
||||
# (Wazuh packages + Intune detectedApps) to real CVEs via OSV/NVD-CPE;
|
||||
# closes the coverage gap for Intune-only / mobile devices.
|
||||
@@ -1349,6 +1568,8 @@ def start_scheduler():
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Rows left "running" by a restart mid-sync would show a spinner forever.
|
||||
mark_interrupted_runs()
|
||||
scheduler.start()
|
||||
logger.info(
|
||||
"Background scheduler started (SLA Breach Checker + Threat Intel Refresh + KEV Alert Hourly + Vulnrichment Nightly + Compliance SCA Nightly + URS Nightly + Audit-Log Prune)"
|
||||
|
||||
@@ -183,11 +183,14 @@ def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
|
||||
source: str = "android-asb", label: str = "ASB") -> None:
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
|
||||
cve_id = cve.upper()
|
||||
# Google's rating in the ASB, Samsung's in the SMR.
|
||||
vendor = "Samsung" if source == "samsung-smr" else "Android"
|
||||
existing = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
|
||||
.first())
|
||||
if existing:
|
||||
existing.add_source(source)
|
||||
existing.set_vendor_severity(vendor, sev)
|
||||
if not existing.package_name:
|
||||
existing.package_name = f"Android ({label} {month[:7]})"[:255]
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
@@ -208,6 +211,7 @@ def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
|
||||
detected_at=datetime.now(),
|
||||
sources=json.dumps([source]), first_detected_by=source,
|
||||
)
|
||||
row.set_vendor_severity(vendor, sev)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
try:
|
||||
|
||||
@@ -238,6 +238,16 @@ _REGISTRY: List[tuple] = [
|
||||
# forever. Only the product entry itself carries the real build.
|
||||
_cpe(r"^microsoft exchange server subscription edition$",
|
||||
"microsoft:exchange_server_subscription_edition"),
|
||||
# Entra Connect Sync, formerly Azure AD Connect — one product, one 1.x/2.x
|
||||
# build line, and NVD files it under both names (entra_connect since the
|
||||
# rename, CVE-2026-65673; azure_active_directory_connect before it).
|
||||
# ANCHORED and exact: the same server carries the Health Agent (4.5.x) and
|
||||
# the pass-through Authentication Agent / Agent Updater (1.5.x), and 1.5.x
|
||||
# sits inside the 1.0.0 .. <2.6.84.0 range. The sync-engine row carries the
|
||||
# product's own build and dedups with it.
|
||||
_cpe(r"^microsoft (entra|azure ad) connect( sync)?( synchronization services)?$",
|
||||
"microsoft:entra_connect",
|
||||
also=["microsoft:azure_active_directory_connect"]),
|
||||
# Checkmk agent. Wazuh does not detect it (wazuh/wazuh#35646), and its
|
||||
# inline patch numbering (2.4.0p12) is what kept every path away.
|
||||
_cpe(r"checkmk agent|check_mk agent|checkmk(?!.*server)", "checkmk:checkmk"),
|
||||
@@ -302,10 +312,194 @@ _OS_REGISTRY: List[tuple] = [
|
||||
{"key": "cpe:igel:ums",
|
||||
"cpe": "cpe:2.3:a:igel:universal_management_suite",
|
||||
"label": "IGEL Universal Management Suite"}),
|
||||
# HPE Aruba switches and controllers, inventoried by the Netdisco
|
||||
# connector. Anchored and ordered CX → Switch → AOS, because "arubaos-cx"
|
||||
# starts with "arubaos" and a loose pattern would put a campus switch's
|
||||
# 10.13.1005 next to a Mobility controller's 10.7.2.2.
|
||||
#
|
||||
# This is the NVD half of Aruba coverage; the other half — HPE's own CNA
|
||||
# records — comes from cvelistv5_scan_service.scan_asset_aruba, and neither
|
||||
# is redundant. NVD carries proper cpeMatch ranges for the three 2023
|
||||
# ArubaOS-Switch CVEs, which cvelistV5 states as prose and can only partly
|
||||
# parse; the current AOS-CX batch (CVE-2026-73749, -44880, -73775 ff.) is
|
||||
# the reverse — clean CNA ranges, and NVD enrichment that arrives days
|
||||
# later.
|
||||
(re.compile(r"^(hpe )?(aruba ?)?(arubaos|aos)[- ]?cx\b", re.I),
|
||||
{"key": "cpe:hpe:arubaos-cx", "cpe": "cpe:2.3:o:hpe:arubaos-cx",
|
||||
"label": "ArubaOS-CX"}),
|
||||
(re.compile(r"^(hpe )?(aruba ?)?(arubaos|aos)[- ]?s(witch)?\b", re.I),
|
||||
{"key": "cpe:hpe:arubaos-switch", "cpe": "cpe:2.3:o:hpe:arubaos-switch",
|
||||
"label": "ArubaOS-Switch"}),
|
||||
# The Mobility controllers/gateways. NVD files them under two vendor
|
||||
# spellings — arubanetworks (250 versions) and the older hp one (32) — for
|
||||
# the same product, so both are queried and the results merged per CVE.
|
||||
(re.compile(r"^(hpe )?aruba ?os\b|^arubaos\b|^aos-w\b", re.I),
|
||||
{"key": "cpe:arubanetworks:arubaos", "cpe": "cpe:2.3:o:arubanetworks:arubaos",
|
||||
"also": ["cpe:2.3:o:hp:arubaos"], "label": "ArubaOS"}),
|
||||
# Cisco IOS XE / IOS XR routers and switches, inventoried by the Netdisco
|
||||
# connector. The asset's OS string is written by netdisco_service as one of
|
||||
# two canonical labels, never as the "ios-xe" slug SNMP::Info answers with.
|
||||
#
|
||||
# These two are the only matching mode in this file that is EXACT and not a
|
||||
# range, and that is not a simplification — it is what both sources state.
|
||||
# NVD enumerates one CPE per affected release (267 of them for
|
||||
# CVE-2026-20267) with no versionStart/End at all, and Cisco's CNA records
|
||||
# do the same (268 bare `version` entries). So `_in_range` takes its
|
||||
# exact-version branch for every one of them, which is why that branch had
|
||||
# to learn about release suffixes: 17.15.1, 17.15.1w, 17.15.1x and 17.15.1y
|
||||
# are four separate releases sharing one set of numbers.
|
||||
#
|
||||
# Classic IOS (the "IOS" product, 12.x/15.x) is deliberately absent: it
|
||||
# numbers releases as "15.2(7)E3", which NVD escapes into the CPE as
|
||||
# `15.2\(7\)e3` and no version scheme here reads. Those devices get an
|
||||
# asset and no CVE verdict, the same as any other unmatched vendor.
|
||||
#
|
||||
# This is the NVD half; the cvelistV5 half is
|
||||
# cvelistv5_scan_service.scan_asset_cisco, and neither is redundant —
|
||||
# verified 08.09.2026, the IOS XR hardening batch (CVE-2026-20274..20280)
|
||||
# was still "Awaiting Analysis" at NVD with zero CPE entries while Cisco's
|
||||
# own records already enumerated all 112 affected releases.
|
||||
#
|
||||
# `lettered` is what lets the version through at all: the shared, strict
|
||||
# _clean_version reads "17.15.4c" as not-a-version, which meant no version,
|
||||
# which meant no scan. It stays strict for everything else — see
|
||||
# _lettered_version for why that split is deliberate.
|
||||
(re.compile(r"^cisco\s+ios[- ]?xe\b", re.I),
|
||||
{"key": "cpe:cisco:ios_xe", "cpe": "cpe:2.3:o:cisco:ios_xe",
|
||||
"label": "Cisco IOS XE", "lettered": True}),
|
||||
(re.compile(r"^cisco\s+ios[- ]?xr\b", re.I),
|
||||
{"key": "cpe:cisco:ios_xr", "cpe": "cpe:2.3:o:cisco:ios_xr",
|
||||
"label": "Cisco IOS XR", "lettered": True}),
|
||||
# Extreme Networks EXOS (marketed as "Switch Engine" since 2022), the third
|
||||
# firmware line the Netdisco connector inventories. Ordinary ranges and an
|
||||
# ordinary version — plain dotted numbers, so no `lettered` reader here.
|
||||
#
|
||||
# BOTH CPE names are queried, and neither is optional: NVD files the same
|
||||
# switch firmware under two products and splits the CVEs between them.
|
||||
# Verified 08.09.2026 — `extremexos` answers CVE-2024-27453, CVE-2020-18305
|
||||
# and the 2017 batch; `exos` answers CVE-2023-43118..43121, the Chalet
|
||||
# directory-traversal and privilege-escalation flaws, and NOTHING under the
|
||||
# other name. A switch on 22.6 gets three findings from each.
|
||||
#
|
||||
# This is the NVD half, and for EXOS it is the load-bearing one: Extreme's
|
||||
# CNA only started filing in 2025, so every earlier EXOS CVE was filed by
|
||||
# MITRE with vendor "n/a" and carries no structured cvelistV5 data at all.
|
||||
# The other half (cvelistv5_scan_service.scan_asset_extreme) is what sees
|
||||
# the current ones — CVE-2026-8169 and -8170 were still "Awaiting Analysis"
|
||||
# at NVD with zero CPE entries while Extreme's own records already stated
|
||||
# all four affected ranges.
|
||||
#
|
||||
# ExtremeWare XOS (`extremeware_xos`, 10.x/11.x, discontinued 2010) is left
|
||||
# out: Netdisco reports those as the "extremeware" slug, not "xos", so no
|
||||
# asset this scanner writes could ever carry a version in that range.
|
||||
(re.compile(r"^(extreme\s+networks\s+)?"
|
||||
r"(extremexos|extreme\s+x?os|exos|switch\s+engine)\b", re.I),
|
||||
{"key": "cpe:extremenetworks:extremexos",
|
||||
"cpe": "cpe:2.3:o:extremenetworks:extremexos",
|
||||
"also": ["cpe:2.3:o:extremenetworks:exos"], "label": "ExtremeXOS"}),
|
||||
# Ubiquiti UniFi access points, the fourth Netdisco firmware line.
|
||||
#
|
||||
# NVD files the AP firmware as `ui:unifi_uap_firmware` and pairs it with a
|
||||
# long list of `h:ui:*` hardware CPEs (U6-LR, UAP-AC-Pro, UBB …). Only the
|
||||
# `o:` half carries versions, so that is the one queried; the hardware
|
||||
# entries name which models the record covers and NVD applies them to the
|
||||
# same records anyway.
|
||||
#
|
||||
# `ui:unifi_switch_firmware` is deliberately NOT an `also` here, and the
|
||||
# reason is the whole point of this entry: a UniFi switch and a UniFi AP
|
||||
# number their firmware in the SAME scheme (6.5.32 vs 6.5.53 on
|
||||
# CVE-2023-38034) and Ubiquiti ships them separate fixes. Matched against
|
||||
# the AP CPE, a USW-24 on 6.5.40 would read as an unpatched AP. The switch
|
||||
# firmware needs its own key the day a UniFi switch turns up in Netdisco;
|
||||
# until then netdisco_service.ubiquiti_family only claims AP models.
|
||||
#
|
||||
# Verified 09.09.2026 — the CPE carries exactly two CVEs (CVE-2023-35085,
|
||||
# CVE-2023-38034). The current one, CVE-2024-37380, has been "Deferred" at
|
||||
# NVD since publication with zero CPE entries, which is what the cvelistV5
|
||||
# half (cvelistv5_scan_service.scan_asset_ubiquiti) is there for.
|
||||
(re.compile(r"^(ubiquiti\s+)?unifi\s+ap\b", re.I),
|
||||
{"key": "cpe:ui:unifi_uap_firmware",
|
||||
"cpe": "cpe:2.3:o:ui:unifi_uap_firmware", "label": "UniFi AP"}),
|
||||
# Brocade Fabric OS — the FC SAN switch firmware, filed under Broadcom
|
||||
# since the acquisition. Fifth Netdisco firmware line, and the ONLY one
|
||||
# covered from this side alone: Brocade's CNA writes its bounds as English
|
||||
# ("before 9.2.1c3, and 9.2.2 though 9.2.2b" — the typo is theirs), which
|
||||
# `_is_version` rejects and no range collector can read, so cvelistV5
|
||||
# carries no structured data for any of them. NVD's analysts turn the same
|
||||
# prose into proper cpeMatch ranges, which is why this half is load-bearing
|
||||
# here. Verified 09.09.2026 on CVE-2024-29954, CVE-2025-9711, CVE-2026-0383.
|
||||
#
|
||||
# `lettered`, and here that is not a spelling nicety like it is for Cisco:
|
||||
# Fabric OS numbers its rebuilds 9.2.1c3 / 9.1.1d / 8.2.3e and NVD states
|
||||
# the bounds in the same shape, so the strict _clean_version reads the
|
||||
# installed 9.0.1e1 as not-a-version and the switch is never scanned at
|
||||
# all. The letters are then ORDERED rather than dropped — see _vparse.
|
||||
#
|
||||
# Brocade's IP switches (ICX/FastIron, NetIron) run different firmware
|
||||
# under the same brand. They are kept out on the Netdisco side, where the
|
||||
# os slug still tells them apart — see netdisco_service.brocade_family.
|
||||
(re.compile(r"^(brocade\s+|broadcom\s+)?fabric\s*os\b", re.I),
|
||||
{"key": "cpe:broadcom:fabric_operating_system",
|
||||
"cpe": "cpe:2.3:o:broadcom:fabric_operating_system",
|
||||
"label": "Brocade Fabric OS", "lettered": True}),
|
||||
# NetScaler ADC / Gateway, the sixth Netdisco firmware line. One appliance
|
||||
# firmware under two CPEs with identical ranges, so both are queried; the
|
||||
# pre-2023 names (application_delivery_controller_firmware,
|
||||
# netscaler_gateway_firmware) answer nothing for a 13.1 or 14.1 build.
|
||||
#
|
||||
# `netscaler` reads the build as NVD writes it, "14.1-72.61", which the
|
||||
# strict reader rejects for the hyphen. It is validated by
|
||||
# cvelistv5_scan_service.netscaler_build, which also withholds the FIPS
|
||||
# build lines.
|
||||
#
|
||||
# `skip_editions`: NVD states the FIPS and NDcPP builds as sw_edition
|
||||
# entries with their own bounds, and its virtualMatchString does not filter
|
||||
# on them. Verified 15.09.2026: a patched 14.1-73.32 still gets
|
||||
# CVE-2026-19490 back, from the fips entry "14.1-66.68 .. 14.1-73.32
|
||||
# including". A normal appliance is never a FIPS build.
|
||||
#
|
||||
# The cvelistV5 half is cvelistv5_scan_service.scan_asset_netscaler, and it
|
||||
# is load-bearing: CVE-2026-19489 was "Awaiting Analysis" with no CPE.
|
||||
(re.compile(r"^(citrix\s+)?netscaler\s+(adc|gateway)\b", re.I),
|
||||
{"key": "cpe:citrix:netscaler_adc",
|
||||
"cpe": "cpe:2.3:a:citrix:netscaler_application_delivery_controller",
|
||||
"also": ["cpe:2.3:a:citrix:netscaler_gateway"],
|
||||
"label": "NetScaler ADC", "netscaler": True,
|
||||
"skip_editions": ["fips", "ndcpp"]}),
|
||||
# Kemp LoadMaster, the seventh Netdisco firmware line. NVD states one range
|
||||
# per release line (GA, LTSF 7.2.54, EOL 7.2.48, 7.1.35), and the unbounded
|
||||
# ones overlap: a patched LTSF or 7.1.35 box sits inside another line's
|
||||
# range. `loadmaster` hands a CVE's ranges to
|
||||
# cvelistv5_scan_service.loadmaster_fix, which decides per line; the
|
||||
# cvelistV5 half is scan_asset_loadmaster.
|
||||
(re.compile(r"^(kemp\s+|progress\s+)?loadmaster\b", re.I),
|
||||
{"key": "cpe:progress:loadmaster", "cpe": "cpe:2.3:o:progress:loadmaster",
|
||||
"label": "Kemp LoadMaster", "loadmaster": True}),
|
||||
# Sophos Firewall (SFOS), the eighth Netdisco firmware line. firewall_firmware
|
||||
# carries the records since 2022; a:sophos:firewall adds CVE-2022-3236 and
|
||||
# CVE-2023-5552, the latter nowhere else structured. The other names NVD used
|
||||
# (o:sophos:firewall, sfos, xg_firewall_firmware) hold only CVE-2022-1807,
|
||||
# which cvelistV5 has, and pre-2021 CVEs no advisory row covers, so they are
|
||||
# not queried (checked 16.09.2026). The bounds ignore the
|
||||
# hotfixes Sophos installs without changing the version, so `sophos` hands
|
||||
# each matched CVE to cvelistv5_scan_service.sophos_fix, which applies the
|
||||
# advisory's hotfixed and per-line fixed releases; the cvelistV5 half is
|
||||
# scan_asset_sophos.
|
||||
(re.compile(r"^sophos\s+firewall\b", re.I),
|
||||
{"key": "cpe:sophos:firewall_firmware", "cpe": "cpe:2.3:o:sophos:firewall_firmware",
|
||||
"also": ["cpe:2.3:a:sophos:firewall"],
|
||||
"label": "Sophos Firewall", "sophos": True}),
|
||||
(re.compile(r"ipad", re.I), {"key": "cpe:apple:ipados",
|
||||
"cpe": "cpe:2.3:o:apple:ipados", "label": "Apple iPadOS"}),
|
||||
(re.compile(r"ios|iphone", re.I), {"key": "cpe:apple:iphone_os",
|
||||
"cpe": "cpe:2.3:o:apple:iphone_os", "label": "Apple iOS"}),
|
||||
# ANCHORED, unlike the loose "ios|iphone" this used to be. Netdisco reports
|
||||
# a Cisco switch's OS as literally "ios" and vendor+os reads "cisco ios" —
|
||||
# matched as a substring, every Cisco switch in the estate would have been
|
||||
# compared against Apple's iPhone CVE ranges, and a 15.2 IOS release sits
|
||||
# below every iOS bound ever written. Intune/Defender/Wazuh all report the
|
||||
# Apple string as "iOS"/"iPadOS"/"iPhone OS", which still match.
|
||||
(re.compile(r"^(apple\s+)?(ios|iphone(\s*os)?)(\s|$)", re.I),
|
||||
{"key": "cpe:apple:iphone_os",
|
||||
"cpe": "cpe:2.3:o:apple:iphone_os", "label": "Apple iOS"}),
|
||||
(re.compile(r"mac ?os|macos|mac_os|os x", re.I), {"key": "cpe:apple:macos",
|
||||
"cpe": "cpe:2.3:o:apple:macos", "label": "Apple macOS"}),
|
||||
]
|
||||
@@ -416,11 +610,22 @@ def _os_family(os_name: str) -> Optional[str]:
|
||||
return "windows"
|
||||
if "ipad" in n:
|
||||
return "ipados"
|
||||
if "iphone" in n or "ios" in n:
|
||||
# ANCHORED, for the reason the Apple entry in _OS_REGISTRY is: "ios" as a
|
||||
# substring also lives inside "cisco ios", which is how Netdisco names a
|
||||
# Cisco switch's firmware. Read as Apple's, a switch on 15.2 would be
|
||||
# compared against the iOS 15 train by scan_asset_os_apple.
|
||||
if re.match(r"^(apple\s+)?(iphone|ios)(\s|$)", n):
|
||||
return "iphone_os"
|
||||
if "android" in n:
|
||||
return "android"
|
||||
if "mac" in n or "darwin" in n or "os x" in n:
|
||||
# "os x" is WORD-BOUNDED, for exactly the reason the iPhone pattern above
|
||||
# is anchored — and it is the same trap one letter further along. "os x"
|
||||
# lives inside "cisco iOS XE"/"cisco iOS XR" as a plain substring, so a
|
||||
# router was read as a Mac: scan_asset_os_apple would then compare its
|
||||
# 17.15.4c against macOS ranges (below every macOS 26 bound, so it matched
|
||||
# them), and _platform_ok would drop the router's own NVD findings for
|
||||
# naming the wrong platform.
|
||||
if "mac" in n or "darwin" in n or re.search(r"\bos x\b", n):
|
||||
return "macos"
|
||||
# IGEL OS is a Debian-based Linux, and its CNA says so — CVE-2025-34082
|
||||
# lists platforms ["Linux"]. Placing it here is what lets the platform
|
||||
@@ -556,6 +761,70 @@ def _clean_version(v: str) -> Optional[str]:
|
||||
return core if re.fullmatch(r"\d+(\.\d+)*(p\d+)?", core) else None
|
||||
|
||||
|
||||
# Everything after the dotted-numeric head — "17.15.4c" → "c", "3.10.1xbS" →
|
||||
# "xbs", "2.4.0p12" → "p12", "16.11.0016" → "".
|
||||
_VHEAD_RE = re.compile(r"^\d+(?:\.\d+)*")
|
||||
|
||||
# _clean_version, plus a TRAIN/REBUILD LETTER — "17.15.4c", "3.10.1xbS".
|
||||
#
|
||||
# Kept apart from _clean_version rather than folded into it, deliberately.
|
||||
# That rule is shared by thirteen call sites across six services, and its
|
||||
# strictness is doing a job everywhere else: it is what keeps distro package
|
||||
# versions out of the NVD queries, and — more to the point — every one of those
|
||||
# paths compares versions as 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 is the obvious victim: 1.1.1a .. 1.1.1w all
|
||||
# read as (1,1,1)).
|
||||
#
|
||||
# Only a registry entry marked `lettered` gets this reader, and only the two
|
||||
# Cisco ones are, because their match is EXACT — the one comparison where the
|
||||
# suffix is honoured in full (see _vsuffix / _in_range).
|
||||
# ponytail: 1-3 letters + optional digits covers every Cisco train letter
|
||||
# observed (39 distinct suffixes across the IOS XE release history: "c", "SG",
|
||||
# "aSP", "xbS", "z2"). A longer one falls back to "no version", which means no
|
||||
# scan — the safe direction. Widen it when one turns up.
|
||||
_LETTERED_RE = re.compile(r"\d+(\.\d+)*([a-z]{1,3}\d*)?", re.I)
|
||||
|
||||
|
||||
def _lettered_version(v: str) -> Optional[str]:
|
||||
"""Release version for a product whose rebuilds carry a letter, or None.
|
||||
|
||||
Returned in the spelling the SOURCES use, because for these products the
|
||||
match is exact and a spelling difference is a miss:
|
||||
|
||||
* lowercased — Cisco writes "3.2.0SG" and NVD's CPE writes "3.2.0sg"
|
||||
(CPE version components are lowercase by spec);
|
||||
* zero-padding dropped per component — Cisco's own release notes write
|
||||
one build as both "17.09.05a" and "17.9.5a", and only the second
|
||||
spelling exists in NVD and cvelistV5.
|
||||
"""
|
||||
v = (v or "").strip()
|
||||
if not v or ":" in v:
|
||||
return None
|
||||
core = re.split(r"[ (+]", v, 1)[0]
|
||||
if not _LETTERED_RE.fullmatch(core):
|
||||
return None
|
||||
core = core.lower()
|
||||
nums, suffix = _VHEAD_RE.match(core).group(0), core[_VHEAD_RE.match(core).end():]
|
||||
return ".".join(str(int(x)) for x in nums.split(".")) + suffix
|
||||
|
||||
|
||||
def _vsuffix(v: str) -> str:
|
||||
"""The release suffix of a version, lowercased — "" when there is none.
|
||||
|
||||
Vendors that ship rebuilds letter them, and the letter IS the release: NVD
|
||||
lists cisco:ios_xe 17.15.4, 17.15.4a, 17.15.4b, 17.15.4c and 17.15.4d as
|
||||
five separate CPEs, and a given CVE names some of them and not others.
|
||||
_vtuple reads all five as (17,15,4), so a numeric comparison calls them
|
||||
equal — which is right for a RANGE bound (a range has a floor and a
|
||||
ceiling to confine it) and wrong for an EXACT version, where equality is
|
||||
the whole verdict.
|
||||
"""
|
||||
t = (v or "").strip()
|
||||
m = _VHEAD_RE.match(t)
|
||||
return t[m.end():].lower() if m else ""
|
||||
|
||||
|
||||
# Adobe renamed its version scheme mid-life: Acrobat/Reader DC shipped as
|
||||
# 2019.010.20098 (four-digit year) until the 2020 release, then dropped the
|
||||
# century — 20.001.30005, and today 26.001.21771. Both spellings mean the same
|
||||
@@ -606,8 +875,47 @@ def _vtuple(v: str) -> Optional[tuple]:
|
||||
return tuple(int(x) for x in nums[:6])
|
||||
|
||||
|
||||
# A dotted version with a trailing RELEASE LETTER and optional rebuild digit —
|
||||
# "9.2.1c3", "8.2.3e", "1.1.1w". Brocade numbers every Fabric OS build this way
|
||||
# and states its bounds in the same shape, and the letter is the release: 9.2.1a
|
||||
# is older than 9.2.1c3, which is older than 9.2.1d.
|
||||
_SUFFIXED_V_RE = re.compile(r"^(\d+(?:\.\d+)*)([a-z]{1,3})(\d*)$", re.I)
|
||||
|
||||
|
||||
def _vparse(v: str) -> Optional[tuple]:
|
||||
"""(numbers, letter-ordinals, rebuild) for a suffix-lettered version.
|
||||
|
||||
None when the string is not a plain dotted version with at most a trailing
|
||||
release letter — the caller falls back to _vtuple then.
|
||||
|
||||
This exists because _vtuple reads the letter's digits as another numeric
|
||||
component and the letter itself as nothing: "9.2.1c3" becomes (9,2,1,3) and
|
||||
"9.2.1d" becomes (9,2,1). Compared against a bound of "below 9.2.1c3", a
|
||||
switch already on 9.2.1d sits below it and collects a CVE it was patched
|
||||
for. Sorting the letters is the only way to order builds a vendor numbers
|
||||
this way.
|
||||
"""
|
||||
t = (v or "").strip()
|
||||
m = _SUFFIXED_V_RE.match(t)
|
||||
if m:
|
||||
nums, letters, tail = m.group(1), m.group(2).lower(), m.group(3)
|
||||
elif re.fullmatch(r"\d+(\.\d+)*", t):
|
||||
nums, letters, tail = t, "", ""
|
||||
else:
|
||||
return None
|
||||
ns = tuple(int(x) for x in nums.split("."))[:6]
|
||||
return (ns + (0,) * (6 - len(ns)),
|
||||
tuple(ord(c) - 96 for c in letters), int(tail or 0))
|
||||
|
||||
|
||||
def _vcmp(a: str, b: str) -> Optional[int]:
|
||||
"""-1/0/1, or None when not comparable."""
|
||||
# Only when a letter is actually in play, so every all-numeric comparison —
|
||||
# which is nearly all of them, Adobe's year rewrite included — keeps going
|
||||
# through _vtuple exactly as before.
|
||||
pa, pb = _vparse(a), _vparse(b)
|
||||
if pa and pb and (pa[1] or pb[1]):
|
||||
return (pa > pb) - (pa < pb)
|
||||
ta, tb = _vtuple(a), _vtuple(b)
|
||||
if ta is None or tb is None:
|
||||
return None
|
||||
@@ -706,6 +1014,27 @@ def _in_range(installed: str, m: dict) -> bool:
|
||||
# could still arrive through NVD.
|
||||
if any(b is not None and not is_version(b) for b in (sI, sE, eI, eE)):
|
||||
return False
|
||||
if (sI or sE) and not (eI or eE):
|
||||
# "From X upward, no ceiling" is not a per-host verdict. NVD writes it
|
||||
# when nobody recorded where the flaw was fixed, and read literally it
|
||||
# says every release the vendor will EVER ship is affected — a finding
|
||||
# that can never be closed by patching, which is the definition of
|
||||
# noise here.
|
||||
#
|
||||
# CVE-2019-12660 is the case that surfaced it: its entire NVD
|
||||
# configuration is ONE entry, `cisco:ios_xe` with
|
||||
# versionStartIncluding 16.1.1 and no end bound at all. Cisco fixed it
|
||||
# in 2019 and its advisory says "at the time of publication ... 16.x
|
||||
# and later"; the 17.15 train did not exist until 2024. Every IOS XE
|
||||
# router in the estate carried it, with "fixed in: n/a", for good.
|
||||
#
|
||||
# This is the rule BOTH other range readers already apply — see
|
||||
# _ranges_from_affected ("only ranges with a real upper bound") and
|
||||
# vmware_entries ("affected, fix unknown cannot be turned into a
|
||||
# per-host verdict") — so this half was the outlier, not the fix.
|
||||
# Per ENTRY, not per CVE: a record that also carries a bounded entry
|
||||
# still reports through that one.
|
||||
return False
|
||||
if _is_adobe_acrobat(m.get("criteria", "")):
|
||||
for b in (sI, sE, eI, eE):
|
||||
if b and not _same_version_scheme(installed, b):
|
||||
@@ -717,7 +1046,16 @@ def _in_range(installed: str, m: dict) -> bool:
|
||||
ver = parts[5] if len(parts) > 5 else "*"
|
||||
if ver in ("*", "-", ""):
|
||||
return False # wildcard → would match everything → skip (FP)
|
||||
return _vcmp(installed, ver) == 0
|
||||
# BOTH halves. The numbers alone are not the version when the vendor
|
||||
# letters its rebuilds: cisco:ios_xe 17.15.1, 17.15.1w, 17.15.1x and
|
||||
# 17.15.1y all read as (17,15,1), so a host on 17.15.1y matched every
|
||||
# CVE that names only the base 17.15.1 — three false positives out of
|
||||
# four on any Cisco router. The suffix is compared as a string because
|
||||
# that is all it is; the numeric half stays for the spellings that
|
||||
# differ without meaning to (Adobe's 19.010 vs 2019.010, SAP's 8.0 vs
|
||||
# 8.00), which is why this is not a plain string equality.
|
||||
return (_vcmp(installed, ver) == 0
|
||||
and _vsuffix(installed) == _vsuffix(ver))
|
||||
if sI is not None and (_vcmp(installed, sI) is None or _vcmp(installed, sI) < 0):
|
||||
return False
|
||||
if sE is not None and (_vcmp(installed, sE) is None or _vcmp(installed, sE) <= 0):
|
||||
@@ -751,7 +1089,9 @@ def _query_osv(eco: str, name: str, version: str) -> List[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List[dict]:
|
||||
def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
|
||||
skip_editions: Optional[list] = None,
|
||||
loadmaster: bool = False, sophos: bool = False) -> List[dict]:
|
||||
api_key = os.getenv("NVD_API_KEY", "").strip()
|
||||
headers = {"apiKey": api_key} if api_key else None
|
||||
url = f"{NVD_CVE_API}?virtualMatchString={cpe}:{version}&resultsPerPage=200"
|
||||
@@ -790,14 +1130,22 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
|
||||
matched = False
|
||||
fixed = None
|
||||
tsws: set = set()
|
||||
lm_ranges: list = []
|
||||
for cfg in cve_obj.get("configurations", []) or []:
|
||||
for node in cfg.get("nodes", []) or []:
|
||||
for m in node.get("cpeMatch", []) or []:
|
||||
crit = m.get("criteria") or ""
|
||||
if prod_token not in crit:
|
||||
continue
|
||||
if prod_token not in crit and not (
|
||||
loadmaster and ":progress:multi-tenant_" in crit):
|
||||
continue # Multi Tenant: its 7.1.35 line, see loadmaster_fix
|
||||
if not m.get("vulnerable", True):
|
||||
continue
|
||||
if skip_editions and (crit.split(":") + [""] * 10)[9].lower() \
|
||||
in skip_editions:
|
||||
continue # sw_edition, see the registry entry
|
||||
if loadmaster:
|
||||
lm_ranges.append(_loadmaster_range(m))
|
||||
continue # decided per CVE below
|
||||
ok = (_sap_affected(m, version, sap_pl) if sap_pl is not None
|
||||
else _in_range(version, m))
|
||||
if ok:
|
||||
@@ -830,6 +1178,14 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
|
||||
end_incl = m.get("versionEndIncluding")
|
||||
if end_incl and is_version(end_incl):
|
||||
fixed = fixed or f">{end_incl}"
|
||||
if loadmaster:
|
||||
from app.services.cvelistv5_scan_service import loadmaster_fix
|
||||
fixed = loadmaster_fix(version, lm_ranges, cve_id)
|
||||
matched, tsws = bool(fixed), {"*"}
|
||||
if sophos:
|
||||
from app.services.cvelistv5_scan_service import sophos_fix
|
||||
fixed = sophos_fix(version, cve_id) if matched else None
|
||||
matched, tsws = bool(fixed), {"*"}
|
||||
if not matched:
|
||||
continue
|
||||
cvss, sev = _nvd_cvss(cve_obj)
|
||||
@@ -848,6 +1204,19 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
|
||||
return out
|
||||
|
||||
|
||||
def _loadmaster_range(m: dict) -> Optional[tuple]:
|
||||
"""One cpeMatch entry as a LoadMaster range (see loadmaster_fix)."""
|
||||
from app.services.cvelistv5_scan_service import loadmaster_range
|
||||
sI, sE = m.get("versionStartIncluding"), m.get("versionStartExcluding")
|
||||
eI, eE = m.get("versionEndIncluding"), m.get("versionEndExcluding")
|
||||
if not any((sI, sE, eI, eE)):
|
||||
exact = (m.get("criteria") or "").split(":")[5:6]
|
||||
if not exact or exact[0] in ("*", "-"):
|
||||
return None
|
||||
return loadmaster_range(exact[0], True, exact[0], True)
|
||||
return loadmaster_range(sI or sE, bool(sI), eE or eI, not eE)
|
||||
|
||||
|
||||
def _nvd_cvss(cve_obj: dict) -> Tuple[Optional[float], Optional[str]]:
|
||||
metrics = cve_obj.get("metrics", {}) or {}
|
||||
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
|
||||
@@ -910,12 +1279,16 @@ def lookup_cves(db: Session, entry: dict, version: str,
|
||||
if entry["kind"] == "osv":
|
||||
cves = _query_osv(entry["eco"], entry["oname"], version)
|
||||
else:
|
||||
cves = _query_nvd_cpe(entry["cpe"], version, sap_pl=sap_pl)
|
||||
skip = entry.get("skip_editions")
|
||||
sophos = bool(entry.get("sophos"))
|
||||
cves = _query_nvd_cpe(entry["cpe"], version, sap_pl=sap_pl, skip_editions=skip,
|
||||
loadmaster=bool(entry.get("loadmaster")), sophos=sophos)
|
||||
# Merge the alternate CPE names, keeping the first entry per CVE —
|
||||
# a duplicate across names is the same finding, not two.
|
||||
for alt in entry.get("also") or []:
|
||||
seen_ids = {c["cve"] for c in cves}
|
||||
cves += [c for c in _query_nvd_cpe(alt, version, sap_pl=sap_pl)
|
||||
cves += [c for c in _query_nvd_cpe(alt, version, sap_pl=sap_pl,
|
||||
skip_editions=skip, sophos=sophos)
|
||||
if c["cve"] not in seen_ids]
|
||||
except _TransientNVD as e:
|
||||
logger.warning("app-cve: %s — not caching, will retry next run", e)
|
||||
@@ -1029,11 +1402,23 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
|
||||
existing.fixed_version = c["fixed"]
|
||||
# The advisory URL, for sources whose id has no public catalogue page:
|
||||
# a TV-2026-1006 finding has no NVD, no CVE.org and no GHSA record, so
|
||||
# the vendor bulletin is its ONLY reference. Fill-only, like the rest.
|
||||
if c.get("refs") and not existing.references:
|
||||
existing.references = c["refs"]
|
||||
# the vendor bulletin is its ONLY reference. Merged rather than
|
||||
# fill-only: another source may already have written its own evidence
|
||||
# (the M365 check attaches its release-notes section), and that must
|
||||
# not cost this finding its bulletin.
|
||||
if c.get("refs"):
|
||||
try:
|
||||
_urls = json.loads(c["refs"])
|
||||
except (TypeError, ValueError):
|
||||
_urls = []
|
||||
for _url in _urls if isinstance(_urls, list) else []:
|
||||
existing.add_reference(_url)
|
||||
if c.get("severity") and existing.severity == VulnerabilitySeverity.medium:
|
||||
existing.severity = sev_map.get(c["severity"].lower(), existing.severity)
|
||||
# ("TeamViewer", "Critical") from sources with a rating of their own;
|
||||
# NVD and cvelistV5 carry none.
|
||||
if c.get("vendor_sev"):
|
||||
existing.set_vendor_severity(*c["vendor_sev"])
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
reopen_if_patched(db, existing, reason="App CVE scan detects this CVE on the host again", source="app_scan")
|
||||
try:
|
||||
@@ -1055,6 +1440,8 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
|
||||
detected_at=datetime.now(),
|
||||
sources=json.dumps(["app-scan"]), first_detected_by="app-scan",
|
||||
)
|
||||
if c.get("vendor_sev"):
|
||||
row.set_vendor_severity(*c["vendor_sev"])
|
||||
db.add(row)
|
||||
db.flush()
|
||||
from app.services.audit_events import record_affected_package
|
||||
@@ -1082,9 +1469,13 @@ def _is_citrix_shim(pkg: dict) -> bool:
|
||||
"""Citrix published-app delivery leaves a registry stub ('Firefox 1.0',
|
||||
vendor 'Delivered by Citrix') for software that is NOT installed on the
|
||||
box — matching it produced ancient-CVE false positives (observed:
|
||||
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub."""
|
||||
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub.
|
||||
|
||||
The delivery phrase, not the bare word: "citrix" also dropped every real
|
||||
Citrix install ("Citrix Workspace 2507", vendor "Citrix Systems, Inc."),
|
||||
so no Citrix CVE ever reached a host (CVE-2026-78546/-78547)."""
|
||||
vendor = (pkg.get("vendor") or "").lower()
|
||||
return "citrix" in vendor
|
||||
return "delivered by citrix" in vendor
|
||||
|
||||
|
||||
def filter_inventory(packages: list) -> list:
|
||||
@@ -1133,9 +1524,43 @@ _EXTENSION_REGISTRY: Dict[str, dict] = {
|
||||
"elhekieabhbkpmcefcoobjddigjcaadp": {
|
||||
"key": "ext:adobe:acrobat:edge", "cpe": "cpe:2.3:a:adobe:acrobat",
|
||||
"label": "Adobe Acrobat (User) extension (Edge)"},
|
||||
# Fortinet Privileged Access Agent (FortiPAM). No "cpe": NVD and cvelistV5
|
||||
# had no record of CVE-2026-84388 ten days after Fortinet published it
|
||||
# (FG-IR-26-168, 2026-09-08), so there is nothing to look up and the
|
||||
# vendor's own statement is the only source. Its CVRF lists 8.0.0 and
|
||||
# 8.0.1 as Known Affected; the advisory text names the fix as extension
|
||||
# 8.0.1.123 (the store build of 2026-08-01). The extension version is the
|
||||
# advisory's version plus a build number, so the range compares directly.
|
||||
# 7.x is NOT listed as affected in the CVRF and stays out until Fortinet
|
||||
# says otherwise. The server half of the fix (FortiPAM 1.9.1 / 1.8.4) is
|
||||
# not visible from a workstation.
|
||||
"gkmiciealelfckimnoopefeplibjciih": {
|
||||
"key": "ext:fortinet:privileged_access_agent:chrome",
|
||||
"label": "Fortinet Privileged Access Agent (User) extension (Chrome)",
|
||||
"vendor": "Fortinet",
|
||||
"advisories": [{
|
||||
"cve": "CVE-2026-84388", "start": "8.0.0", "fix": "8.0.1.123",
|
||||
"cvss": 9.1, "severity": "critical",
|
||||
"desc": ("An improper authentication vulnerability [CWE-287] in the "
|
||||
"Fortinet Privileged Access Agent Chrome Extension may allow a "
|
||||
"remote unauthenticated attacker to proxy a user's browser "
|
||||
"traffic through attacker controlled servers if the user "
|
||||
"visits a malicious website. Also upgrade the FortiPAM server "
|
||||
"to 1.9.1 or 1.8.4."),
|
||||
"refs": '["https://fortiguard.fortinet.com/psirt/FG-IR-26-168"]'}]},
|
||||
}
|
||||
|
||||
|
||||
def _advisory_cves(entry: dict, version: str) -> List[dict]:
|
||||
"""The registry's vendor-advisory rows that cover `version`, in the shape
|
||||
lookup_cves returns. Bounded both ends, like every other range here."""
|
||||
return [{"cve": a["cve"], "cvss": a["cvss"], "severity": a["severity"],
|
||||
"desc": a["desc"], "fixed": a["fix"], "refs": a["refs"]}
|
||||
for a in entry.get("advisories") or []
|
||||
if _in_range(version, {"versionStartIncluding": a["start"],
|
||||
"versionEndExcluding": a["fix"]})]
|
||||
|
||||
|
||||
def resolve_extension(ext: dict) -> Optional[dict]:
|
||||
"""Curated lookup by store id. Unknown extensions are ignored, same rule
|
||||
as the package registry — no guessing from names."""
|
||||
@@ -1184,12 +1609,13 @@ def scan_asset_extensions(db: Session, asset, extensions: list,
|
||||
if key in seen:
|
||||
continue # same extension in several browser profiles
|
||||
seen.add(key)
|
||||
try:
|
||||
cves = lookup_cves(db, {"key": entry["key"], "kind": "cpe",
|
||||
"cpe": entry["cpe"]}, version)
|
||||
except Exception as e:
|
||||
logger.debug("extension lookup failed (%s): %s", entry["label"], e)
|
||||
continue
|
||||
cves = _advisory_cves(entry, version)
|
||||
if entry.get("cpe"):
|
||||
try:
|
||||
cves += lookup_cves(db, {"key": entry["key"], "kind": "cpe",
|
||||
"cpe": entry["cpe"]}, version)
|
||||
except Exception as e:
|
||||
logger.debug("extension lookup failed (%s): %s", entry["label"], e)
|
||||
for c in cves:
|
||||
# Here target_sw is the BROWSER, not the OS — that is exactly the
|
||||
# field that separates the extension's CVEs from the desktop
|
||||
@@ -1200,7 +1626,7 @@ def scan_asset_extensions(db: Session, asset, extensions: list,
|
||||
try:
|
||||
_upsert(db, asset, label, ext.get("version") or version,
|
||||
c, new_ids, touched=touched,
|
||||
vendor=(ext.get("vendor") or "Adobe"))
|
||||
vendor=(ext.get("vendor") or entry.get("vendor", "Adobe")))
|
||||
count += 1 if len(new_ids) > before else 0
|
||||
except Exception as e:
|
||||
logger.debug("extension upsert failed (%s on %s): %s",
|
||||
@@ -1271,11 +1697,23 @@ def scan_asset_os(db: Session, asset, new_ids: list, touched: Optional[set] = No
|
||||
e = _resolve_os(asset.operating_system or "")
|
||||
if not e:
|
||||
return 0
|
||||
cver = _clean_version(asset.os_version or "")
|
||||
if e.get("netscaler"):
|
||||
from app.services.cvelistv5_scan_service import netscaler_build
|
||||
cver = asset.os_version.strip() if netscaler_build(asset.os_version) else None
|
||||
elif e.get("sophos"):
|
||||
from app.services.cvelistv5_scan_service import sophos_version
|
||||
cver = sophos_version(asset.os_version) # "22.0.1 MR-1-Build490" → "22.0.1.490"
|
||||
else:
|
||||
read = _lettered_version if e.get("lettered") else _clean_version
|
||||
cver = read(asset.os_version or "")
|
||||
if not cver:
|
||||
return 0
|
||||
try:
|
||||
cves = lookup_cves(db, {"key": e["key"], "kind": "cpe", "cpe": e["cpe"]}, cver)
|
||||
cves = lookup_cves(db, {"key": e["key"], "kind": "cpe", "cpe": e["cpe"],
|
||||
"also": e.get("also"),
|
||||
"skip_editions": e.get("skip_editions"),
|
||||
"loadmaster": e.get("loadmaster"),
|
||||
"sophos": e.get("sophos")}, cver)
|
||||
except Exception as ex:
|
||||
logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
|
||||
return 0
|
||||
@@ -1400,11 +1838,21 @@ def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
|
||||
return resolved
|
||||
|
||||
|
||||
def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
def run_app_cve_scan(db: Session, asset_id: Optional[int] = None,
|
||||
trigger: str = "manual") -> dict:
|
||||
"""Scan all assets with software inventory (Wazuh packages + Intune
|
||||
detectedApps) → app-scan CVEs, then enrich the new ones."""
|
||||
detectedApps) → app-scan CVEs, then enrich the new ones.
|
||||
|
||||
`trigger` names who started it in the sync-run ledger when the Wazuh
|
||||
API turns out to be dead — the scan logs in to it for every agent's
|
||||
package list, so it is the third job of the night to find that out."""
|
||||
from app.models.asset import Asset, AssetSource
|
||||
from app.services.sync_run_service import probe_wazuh_api, report_source_failure, source_failure
|
||||
from app.services.audit_events import asset_inventory, use_inventory
|
||||
stats = {"assets": 0, "findings": 0, "new": 0, "errors": []}
|
||||
from app.services.cvelistv5_scan_service import take_sophos_no_row
|
||||
take_sophos_no_row() # this run's CVEs only
|
||||
wazuh_errors: list = [] # package/extension fetches only — NVD/OSV 503s are not Wazuh
|
||||
new_ids: list = []
|
||||
logger.info("App CVE scan starting: NVD key %s",
|
||||
"present" if os.getenv("NVD_API_KEY", "").strip() else "MISSING (keyless = frequent 503)")
|
||||
@@ -1425,6 +1873,14 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
verify_ssl=bool(cfg.get("verify_ssl", True)))
|
||||
except Exception as e:
|
||||
logger.debug("app-cve: wazuh client unavailable: %s", e)
|
||||
# One login up front. Refused → recorded as a failed Wazuh run (mail,
|
||||
# cooldown) and the scan goes on without Wazuh inventory: every Wazuh
|
||||
# asset then reports an EMPTY inventory and keeps its findings (see the
|
||||
# auto-resolve guard below), Intune-only assets are scanned as usual.
|
||||
if wazuh and probe_wazuh_api(wazuh, phase="app-scan packages", trigger=trigger):
|
||||
stats["errors"].append("wazuh: login refused — Wazuh inventory skipped this run")
|
||||
stats["wazuh_skipped"] = True
|
||||
wazuh = None
|
||||
try:
|
||||
from app.services.intune_service import load_intune_config, _build_client
|
||||
icfg = load_intune_config(db)
|
||||
@@ -1449,12 +1905,48 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
except Exception as e:
|
||||
logger.warning("app-cve: cvelistV5 index build/load failed: %s", e)
|
||||
|
||||
isn_index = {}
|
||||
try:
|
||||
from app.services import igel_isn_service
|
||||
isn_index = igel_isn_service.get_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("app-cve: IGEL ISN index load failed: %s", e)
|
||||
|
||||
# MSRC fixed-build index (Windows Server, SharePoint, Edge). Rebuilt at
|
||||
# 01:30 by vuln_index_refresh_nightly and loaded here ONCE — it used to be
|
||||
# parsed from its JSON blob again for every asset.
|
||||
#
|
||||
# ensure_index, not load_index: load_index serves the stored blob at any
|
||||
# age, so this pass decided from whatever the last nightly build left
|
||||
# behind. Moving that build to 01:30 fixed the nightly order but not a run
|
||||
# started by hand — after the field report the operator ran MSRC enrichment
|
||||
# and an app scan from the GUI and still saw no Edge finding, because
|
||||
# neither touches this index and the stored one predated the CVE. A scan
|
||||
# someone presses a button for must not answer out of yesterday's index;
|
||||
# 6h keeps the nightly chain on the 01:30 build. A failed rebuild serves
|
||||
# the cache (see ensure_index) — an offline MSRC is no worse than before.
|
||||
m_index = {}
|
||||
try:
|
||||
from app.services import msrc_scan_service
|
||||
m_index = msrc_scan_service.ensure_index(
|
||||
db, max_age=msrc_scan_service.SCAN_INDEX_MAX_AGE) or {}
|
||||
except Exception as e:
|
||||
logger.warning("app-cve: MSRC index build/load failed — MSRC products "
|
||||
"skipped this run: %s", e)
|
||||
m_index = {}
|
||||
|
||||
q = db.query(Asset)
|
||||
if asset_id is not None:
|
||||
q = q.filter(Asset.id == asset_id)
|
||||
for asset in q.all():
|
||||
touched = False
|
||||
touched_cves: set = set() # every CVE re-detected this run → reconcile base
|
||||
# Whose picture of this host are we deciding from? A reopen may not
|
||||
# undo a close that is NEWER than the data claiming it (see
|
||||
# audit_events._reopen_grace). The OS passes below read fields an
|
||||
# inventory sync wrote onto the asset, so start from the inventory that
|
||||
# owns it; the package block refines this to the list that answered.
|
||||
use_inventory(asset_inventory(asset))
|
||||
# Marks the start of THIS asset's pass. Package rows not refreshed past
|
||||
# it were not re-confirmed and get pruned below.
|
||||
asset_scan_started = datetime.now()
|
||||
@@ -1510,6 +2002,19 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} vsphere: {e}")
|
||||
|
||||
# IGEL's own security notices (ISN) — endpoints AND the UMS
|
||||
# server. Before scan_asset_igel so its hits land in
|
||||
# `touched_cves` for that pass's reconcile.
|
||||
try:
|
||||
from app.services import igel_isn_service
|
||||
n = igel_isn_service.scan_asset(db, asset, new_ids,
|
||||
touched=touched_cves, index=isn_index)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} igel-isn: {e}")
|
||||
|
||||
# IGEL OS thin clients. Same shape as vSphere — the firmware
|
||||
# version already sits on the asset, put there by the IGEL UMS
|
||||
# connector — and the same reason for existing: an endpoint that
|
||||
@@ -1527,7 +2032,121 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} igel: {e}")
|
||||
|
||||
# HPE Aruba switches and controllers. Same shape as vSphere and
|
||||
# IGEL — the firmware version already sits on the asset, put there
|
||||
# by the Netdisco connector — and the same reason for existing: a
|
||||
# switch runs no agent, enrols in no MDM and answers a network scan
|
||||
# with an open port and no version.
|
||||
# AFTER the CPE OS scan above on purpose: it folds that scan's hits
|
||||
# into its own reconcile (see scan_asset_aruba).
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
n = cvelistv5_scan_service.scan_asset_aruba(
|
||||
db, asset, cve5_index, new_ids, touched=touched_cves)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} aruba: {e}")
|
||||
|
||||
# Cisco IOS XE / IOS XR routers and switches. Same shape and same
|
||||
# reason as the Aruba pass above — the firmware version sits on the
|
||||
# asset, put there by the Netdisco connector — and AFTER the CPE OS
|
||||
# scan for the same reason: it folds that scan's hits into its own
|
||||
# reconcile (see scan_asset_cisco).
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
n = cvelistv5_scan_service.scan_asset_cisco(
|
||||
db, asset, cve5_index, new_ids, touched=touched_cves)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} cisco: {e}")
|
||||
|
||||
# Extreme Networks EXOS switches. Third of the Netdisco firmware
|
||||
# lines, same shape and same placement as the two above.
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
n = cvelistv5_scan_service.scan_asset_extreme(
|
||||
db, asset, cve5_index, new_ids, touched=touched_cves)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} extreme: {e}")
|
||||
|
||||
# Ubiquiti UniFi access points. Fourth Netdisco firmware line, same
|
||||
# shape and same placement — and the one pass that also reads the
|
||||
# MODEL off the asset's OS string, because half of Ubiquiti's
|
||||
# records apply to a single model (see scan_asset_ubiquiti).
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
n = cvelistv5_scan_service.scan_asset_ubiquiti(
|
||||
db, asset, cve5_index, new_ids, touched=touched_cves)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} ubiquiti: {e}")
|
||||
|
||||
# NetScaler ADC / Gateway. Sixth Netdisco firmware line, same
|
||||
# shape and placement; its bounds are a line plus a build, so it
|
||||
# has a decision of its own (see scan_asset_netscaler).
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
n = cvelistv5_scan_service.scan_asset_netscaler(
|
||||
db, asset, cve5_index, new_ids, touched=touched_cves)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} netscaler: {e}")
|
||||
|
||||
# Kemp LoadMaster, seventh line: one range per release line, decided
|
||||
# per line (see scan_asset_loadmaster).
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
n = cvelistv5_scan_service.scan_asset_loadmaster(
|
||||
db, asset, cve5_index, new_ids, touched=touched_cves)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} loadmaster: {e}")
|
||||
|
||||
# Sophos Firewall, eighth line: the range, then the advisory's
|
||||
# hotfixed and per-line fixed releases (see scan_asset_sophos).
|
||||
try:
|
||||
from app.services import cvelistv5_scan_service
|
||||
n = cvelistv5_scan_service.scan_asset_sophos(
|
||||
db, asset, cve5_index, new_ids, touched=touched_cves)
|
||||
if n:
|
||||
stats["findings"] += n
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} sophos: {e}")
|
||||
|
||||
# Brocade Fabric OS has no pass here: its CNA states the affected
|
||||
# versions as prose, so cvelistV5 carries nothing structured for it
|
||||
# and the NVD-CPE scan above is the whole verdict.
|
||||
|
||||
# Package-level CVEs (Wazuh syscollector / Intune detectedApps).
|
||||
#
|
||||
# `inventory_complete` decides whether this run is allowed to CLOSE
|
||||
# anything on this asset. Detecting from a partial inventory is
|
||||
# harmless — an app that is there is there — but retracting needs the
|
||||
# host's OWN, whole inventory. The two are tracked apart because they
|
||||
# were conflated: the guard below asked only whether `packages` was
|
||||
# non-empty, never where the list came from.
|
||||
inventory_complete = True
|
||||
# The source that owns this asset's software list, and the one that
|
||||
# actually answered. A host registered in Wazuh AND Intune has Wazuh as
|
||||
# its inventory; Intune's detectedApps is a thinner, differently-named
|
||||
# list that does not carry every product syscollector reports.
|
||||
primary_inv = "wazuh" if asset.wazuh_agent_id else (
|
||||
"intune" if asset.intune_device_id else None)
|
||||
served_inv = None
|
||||
packages: list = []
|
||||
try:
|
||||
if asset.wazuh_agent_id and wazuh:
|
||||
@@ -1538,11 +2157,31 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
packages = node_inventory(asset)
|
||||
else:
|
||||
packages = wazuh.get_packages(asset.wazuh_agent_id) or []
|
||||
served_inv = "wazuh"
|
||||
elif asset.intune_device_id and graph:
|
||||
# Reached for an Intune-only asset — and, when the Wazuh login
|
||||
# was refused up front, ALSO for a Wazuh asset that happens to
|
||||
# be enrolled in Intune. That fall-through is kept (its
|
||||
# findings are real), but it is not this host's inventory, so
|
||||
# `served_inv` will not match `primary_inv` and nothing gets
|
||||
# closed from it. Field report 06.09.2026: with the Wazuh API
|
||||
# down, two CVEs on the one dual-registered host were closed as
|
||||
# "no longer detected" against Intune's list, and the next scan
|
||||
# after Wazuh came back reopened both.
|
||||
packages = graph.get_detected_apps(asset.intune_device_id) or []
|
||||
served_inv = "intune"
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id}: {e}")
|
||||
if asset.wazuh_agent_id and wazuh:
|
||||
wazuh_errors.append(f"{asset.hostname}: {e}")
|
||||
packages = []
|
||||
served_inv = None
|
||||
if primary_inv is not None and served_inv != primary_inv:
|
||||
inventory_complete = False
|
||||
# Everything from here decides from `packages`, so the reopen grace
|
||||
# follows THAT list's lag — including the fall-through case, where a
|
||||
# Wazuh host was answered by Intune's older detectedApps.
|
||||
use_inventory(served_inv or asset_inventory(asset))
|
||||
# Filter once, here — every path below shares this list.
|
||||
packages = filter_inventory(packages)
|
||||
|
||||
@@ -1561,6 +2200,12 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
touched = True
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"asset {asset.id} extensions: {e}")
|
||||
wazuh_errors.append(f"{asset.hostname} extensions: {e}")
|
||||
# Extension findings carry the same 'app-scan' source as the
|
||||
# package ones, and nothing else inventories an extension. A
|
||||
# failed fetch here would otherwise leave them reconciled
|
||||
# against a list that cannot contain them.
|
||||
inventory_complete = False
|
||||
if packages:
|
||||
stats["findings"] += scan_asset_packages(db, asset, packages, new_ids, touched=touched_cves)
|
||||
if cve5_index:
|
||||
@@ -1606,20 +2251,18 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
# knows the patch state. Separate source ('msrc') + its own reconcile.
|
||||
try:
|
||||
from app.services import msrc_scan_service
|
||||
m_index = msrc_scan_service.load_index(db)
|
||||
if not m_index:
|
||||
# Absent (or retired by a version bump) → build it now so a
|
||||
# manual scan behaves like the nightly job, instead of
|
||||
# silently skipping every MSRC product.
|
||||
logger.info("app-cve: MSRC index missing — building now")
|
||||
m_index = msrc_scan_service.build_product_index(db) or {}
|
||||
if m_index:
|
||||
m_touched: set = set()
|
||||
stats["msrc_findings"] = stats.get("msrc_findings", 0) + \
|
||||
msrc_scan_service.scan_asset_packages(
|
||||
db, asset, packages, m_index, new_ids, touched=m_touched)
|
||||
stats["msrc_resolved"] = stats.get("msrc_resolved", 0) + \
|
||||
msrc_scan_service.resolve_stale_packages(db, asset, m_touched)
|
||||
# Same rule as the app-scan reconcile below: this one
|
||||
# closes MSRC package findings on the assumption that it
|
||||
# just saw the whole inventory, so it must not run on a
|
||||
# partial one.
|
||||
if inventory_complete:
|
||||
stats["msrc_resolved"] = stats.get("msrc_resolved", 0) + \
|
||||
msrc_scan_service.resolve_stale_packages(db, asset, m_touched)
|
||||
# OS-level MSRC findings too. Their scan+reconcile lived
|
||||
# only in run_msrc_scan (nightly / its own button), so a
|
||||
# patched Windows host kept its MSRC OS findings open until
|
||||
@@ -1632,6 +2275,10 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
stats["msrc_os_resolved"] = stats.get("msrc_os_resolved", 0) + \
|
||||
msrc_scan_service.resolve_stale_os(db, asset, os_touched)
|
||||
except Exception as e:
|
||||
# Loud: the nightly log prints stats without `errors`, so a
|
||||
# pass that died here (no Edge finding, no reconcile) left no
|
||||
# trace at all.
|
||||
logger.warning("app-cve: MSRC pass failed on %s: %s", asset.hostname, e)
|
||||
stats["errors"].append(f"asset {asset.id} msrc: {e}")
|
||||
# Auto-resolve: an app-scan-only finding no longer re-detected means
|
||||
# the software was updated/removed past it. Only safe when we had a
|
||||
@@ -1658,6 +2305,14 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
# cannot trust, never a host that runs no software — so leave its
|
||||
# findings alone. A finding held open one cycle too long is a far
|
||||
# smaller error than several hundred closed on no evidence.
|
||||
#
|
||||
# An inventory that is merely INCOMPLETE is the same problem with
|
||||
# a full-looking list: the Wazuh login was refused and Intune's
|
||||
# detectedApps answered for a dual-registered host, or the
|
||||
# extension fetch died. Non-empty, so the check above passes, and
|
||||
# everything the answering source does not list reads as "no
|
||||
# longer detected". Only the host's own, whole inventory may
|
||||
# retract a finding.
|
||||
from app.models.asset import AssetStatus
|
||||
if not packages and (asset.wazuh_agent_id or asset.intune_device_id):
|
||||
stats["resolve_skipped_no_inventory"] = \
|
||||
@@ -1665,6 +2320,12 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
logger.info("app-cve: %s reported an EMPTY inventory — skipping "
|
||||
"auto-resolve (agent re-registered, or a failed fetch)",
|
||||
asset.hostname)
|
||||
elif not inventory_complete:
|
||||
stats["resolve_skipped_partial_inventory"] = \
|
||||
stats.get("resolve_skipped_partial_inventory", 0) + 1
|
||||
logger.info("app-cve: %s inventory was incomplete this run "
|
||||
"(%s answered, %s owns it) — skipping auto-resolve",
|
||||
asset.hostname, served_inv or "nothing", primary_inv)
|
||||
elif asset.status == AssetStatus.ACTIVE:
|
||||
# Prune first: a finding may stay open on one product while
|
||||
# another of its products is already patched.
|
||||
@@ -1690,9 +2351,15 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
stats["assets"] += 1
|
||||
db.commit()
|
||||
|
||||
use_inventory(None)
|
||||
if graph:
|
||||
graph.close()
|
||||
stats["new"] = len(new_ids)
|
||||
# The API dying mid-scan leaves auth/5xx/connection errors on the assets
|
||||
# after it — a Wazuh failure, not per-asset noise.
|
||||
hit = source_failure(wazuh_errors)
|
||||
if hit:
|
||||
report_source_failure("wazuh", trigger, "app-scan packages", hit)
|
||||
|
||||
# Enrich + audit the new app-scan CVEs.
|
||||
if new_ids:
|
||||
@@ -1750,6 +2417,9 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
except Exception as e:
|
||||
logger.warning("app-cve: FP-suppression failed (non-fatal): %s", e)
|
||||
|
||||
no_row = take_sophos_no_row()
|
||||
if no_row:
|
||||
stats["errors"].append(no_row)
|
||||
logger.info("App CVE scan: %d assets, %d findings (%d new, %d auto-resolved, %d FP-suppressed)",
|
||||
stats["assets"], stats["findings"], stats["new"],
|
||||
stats.get("resolved", 0), stats.get("fp_suppressed", 0))
|
||||
|
||||
@@ -194,6 +194,61 @@ def apply_status(
|
||||
return True
|
||||
|
||||
|
||||
def rename(db: Session, asset: Asset, new_name: str, source: str) -> bool:
|
||||
"""Set `asset.hostname` from a sync and log it, backend and audit log.
|
||||
No-op if unchanged.
|
||||
|
||||
Every sync rename goes through here, for the same reason status changes go
|
||||
through apply_status: a manual rename is audited by the asset router, and
|
||||
without this a sync-driven one left no trace — the old name, still in a
|
||||
ticket or a mail, then pointed at nothing (field request 2026-09-18).
|
||||
"""
|
||||
old = asset.hostname
|
||||
if not new_name or old == new_name:
|
||||
return False
|
||||
asset.hostname = new_name
|
||||
logger.info("%s sync renamed asset %s: %s → %s", source, asset.id, old, new_name)
|
||||
try:
|
||||
from app.models.audit_log import AuditLog, AuditEventType
|
||||
db.add(AuditLog(
|
||||
user_id=None,
|
||||
event_type=AuditEventType.ASSET_UPDATED,
|
||||
event_description=f"Asset renamed by {source} sync: {old} → {new_name}"[:500],
|
||||
resource_type="asset",
|
||||
resource_id=str(asset.id),
|
||||
old_value=old,
|
||||
new_value=json.dumps({"hostname": new_name, "old_hostname": old, "source": source}),
|
||||
timestamp=datetime.now(),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("asset rename audit failed for %s: %s", asset.id, e)
|
||||
return True
|
||||
|
||||
|
||||
def created(db: Session, asset: Asset, source: str) -> None:
|
||||
"""Write the ASSET_CREATED audit entry for an asset a sync just added.
|
||||
|
||||
Every sync create goes through here: a manual create has always been
|
||||
audited by the asset router, a sync-created one only reached the backend
|
||||
log (field request 2026-09-19).
|
||||
"""
|
||||
if asset.id is None:
|
||||
db.flush()
|
||||
try:
|
||||
from app.models.audit_log import AuditLog, AuditEventType
|
||||
db.add(AuditLog(
|
||||
user_id=None,
|
||||
event_type=AuditEventType.ASSET_CREATED,
|
||||
event_description=f"Asset created by {source} sync: {asset.hostname}"[:500],
|
||||
resource_type="asset",
|
||||
resource_id=str(asset.id),
|
||||
new_value=json.dumps({"hostname": asset.hostname, "source": source}),
|
||||
timestamp=datetime.now(),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("asset create audit failed for %s: %s", asset.id, e)
|
||||
|
||||
|
||||
def _stamp_source(asset: Asset) -> str:
|
||||
"""Human-readable name of the sync that wrote this asset's stamps.
|
||||
|
||||
@@ -716,3 +771,74 @@ def reconcile_igel_by_seen_ids(
|
||||
stats["reactivated"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def reconcile_netdisco_by_seen_ids(
|
||||
db: Session,
|
||||
*,
|
||||
seen_asset_ids: set,
|
||||
reason: str,
|
||||
) -> dict:
|
||||
"""Id-keyed Netdisco reconcile — same robust pattern as the IGEL one.
|
||||
|
||||
Candidate set = ACTIVE Netdisco-known assets (source == NETDISCO OR
|
||||
netdisco_device_ip IS NOT NULL) minus the ids seen this sync → INACTIVE;
|
||||
INACTIVE ones seen again → ACTIVE.
|
||||
|
||||
What this does NOT reconcile away: a switch that is merely unreachable
|
||||
right now. Netdisco keeps a device in its inventory until it is deleted or
|
||||
ages out of discovery, so a reboot or a maintenance window never reaches
|
||||
this function — only a device Netdisco no longer lists at all does, which
|
||||
is what a decommissioned switch looks like.
|
||||
|
||||
Fail-open on an empty seen set. DECOMMISSIONED untouched. Caller commits.
|
||||
"""
|
||||
from sqlalchemy import or_ as _or
|
||||
stats = {"inactivated": 0, "reactivated": 0, "candidates": 0}
|
||||
|
||||
if not seen_asset_ids:
|
||||
logger.warning(
|
||||
"netdisco reconcile: skipped — seen_asset_ids empty "
|
||||
"(sync returned no devices? not deactivating anything)"
|
||||
)
|
||||
return stats
|
||||
|
||||
netdisco_known = _or(
|
||||
Asset.source == AssetSource.NETDISCO,
|
||||
Asset.netdisco_device_ip.isnot(None),
|
||||
)
|
||||
|
||||
candidates = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
netdisco_known,
|
||||
Asset.status == AssetStatus.ACTIVE,
|
||||
~Asset.id.in_(seen_asset_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
stats["candidates"] = len(candidates)
|
||||
for asset in candidates:
|
||||
old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
|
||||
asset.status = AssetStatus.INACTIVE
|
||||
_audit_asset_status(db, asset, old, "inactive", reason)
|
||||
stats["inactivated"] += 1
|
||||
|
||||
revived = (
|
||||
db.query(Asset)
|
||||
.filter(
|
||||
netdisco_known,
|
||||
Asset.status == AssetStatus.INACTIVE,
|
||||
Asset.id.in_(seen_asset_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for asset in revived:
|
||||
asset.status = AssetStatus.ACTIVE
|
||||
_audit_asset_status(
|
||||
db, asset, "inactive", "active",
|
||||
"seen again by a Netdisco sync (event-driven revive)",
|
||||
)
|
||||
stats["reactivated"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Wann eine IP-Adresse ein Asset identifiziert — und wann nicht.
|
||||
|
||||
Jede Sync-Match-Kette endet auf derselben letzten Stufe: kein stabiler
|
||||
Identifier getroffen, kein Hostname getroffen, also die Adresse. Die Stufe
|
||||
existiert aus einem guten Grund — ein ESXi steht in vCenter oft nur mit
|
||||
seiner Management-Adresse, und der Nessus-Scan derselben Box soll auf dieses
|
||||
Asset laufen statt ein zweites anzulegen.
|
||||
|
||||
Sie war aber bedingungslos, und eine Adresse ist kein Identifikator: Gastnetze,
|
||||
VPN-Pools, VLAN-Segmente und recycelte DHCP-Leases vergeben dieselbe IP
|
||||
mehrfach. Field report 2026-09-10: ein abgeschalteter Wazuh-Agent und ein
|
||||
UniFi AP teilten sich eine Adresse und damit eine asset_id — Hostname von
|
||||
der einen Quelle, OS von der anderen, und der Status kippte bei jedem Lauf
|
||||
zwischen INACTIVE (Wazuh, Keepalive uralt) und ACTIVE (Netdisco).
|
||||
|
||||
Die Adresse matcht daher nur noch, wenn sie eindeutig ist UND kein Name
|
||||
widerspricht. Der Konsolidierungsfall oben überlebt: dort trägt eine der
|
||||
beiden Seiten gar keinen Namen, sondern ihre eigene Adresse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _nameless(name: Optional[str]) -> bool:
|
||||
"""Eine Quelle, die keinen Namen kennt — oder die Adresse als Namen führt.
|
||||
|
||||
Beides heißt: von dieser Seite kommt kein Widerspruch. Ein Asset, das nach
|
||||
seiner Adresse benannt ist, wurde genau deshalb so angelegt.
|
||||
"""
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
return True
|
||||
try:
|
||||
ipaddress.ip_address(n)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _same_host(a: Optional[str], b: Optional[str]) -> bool:
|
||||
"""FQDN und Kurzform desselben Hosts gelten als derselbe Name."""
|
||||
x = (a or "").strip().lower().split(".")[0]
|
||||
y = (b or "").strip().lower().split(".")[0]
|
||||
return bool(x) and x == y
|
||||
|
||||
|
||||
def different_hosts(a: Optional[str], b: Optional[str]) -> bool:
|
||||
"""Beide Seiten führen einen echten Namen, und es ist nicht derselbe."""
|
||||
return not (_nameless(a) or _nameless(b) or _same_host(a, b))
|
||||
|
||||
|
||||
def _not_pinned_by(q, cols):
|
||||
"""Assets ohne Pin in `cols` — den Identitätsspalten einer Quelle, deren
|
||||
Geräte nie dieses Gerät sein können. Ein Thin Client ist kein Netdisco-
|
||||
Gerät und umgekehrt; trägt ein Asset den Pin der anderen Quelle, gehört es
|
||||
einem anderen Gerät (Field report 2026-09-15: IGEL und Netdisco schrieben
|
||||
jede Nacht abwechselnd einen Thin Client und einen UniFi AP auf eine Zeile).
|
||||
"""
|
||||
for col in cols:
|
||||
q = q.filter(col.is_(None))
|
||||
return q
|
||||
|
||||
|
||||
def match_by_ip(db: Session, ip: Optional[str], name: Optional[str] = None,
|
||||
*, not_pinned_by: tuple = ()) -> Optional[Asset]:
|
||||
"""Das Asset, das diese Adresse identifiziert — oder None.
|
||||
|
||||
`name` ist der Hostname, den die Quelle für dieses Gerät meldet (leer, wenn
|
||||
sie keinen kennt). Kein Treffer, wenn mehrere Assets die Adresse tragen
|
||||
(dann identifiziert sie nichts) oder wenn beide Seiten verschiedene echte
|
||||
Namen führen (dann sind es zwei Geräte).
|
||||
"""
|
||||
ip = (ip or "").strip()
|
||||
if not ip:
|
||||
return None
|
||||
# limit(2): ob die Adresse eindeutig ist, entscheidet sich am zweiten Treffer.
|
||||
rows = _not_pinned_by(db.query(Asset).filter(Asset.ip_address == ip),
|
||||
not_pinned_by).limit(2).all()
|
||||
if len(rows) != 1:
|
||||
if rows:
|
||||
logger.info("asset match: %s wird von mehreren Assets gefuehrt "
|
||||
"— kein Identifikator", ip)
|
||||
return None
|
||||
asset = rows[0]
|
||||
if not different_hosts(name, asset.hostname):
|
||||
return asset
|
||||
logger.info("asset match: %s gehoert zu '%s', die Quelle meldet dort '%s' "
|
||||
"— nicht derselbe Host", ip, asset.hostname, name)
|
||||
return None
|
||||
|
||||
|
||||
def like_literal(name: str) -> str:
|
||||
"""`name` als LIKE-Muster, das nur sich selbst trifft.
|
||||
|
||||
`_` und `%` sind in LIKE Platzhalter: "PC_LAB" traf "PC-LAB", ein anderes
|
||||
Geraet.
|
||||
"""
|
||||
return name.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def pick_one(rows: list, ip: Optional[str], what: str) -> Optional[Asset]:
|
||||
"""Das eine Asset unter gleichnamigen Kandidaten — oder keins.
|
||||
|
||||
Ein Kandidat: der Treffer. Mehrere: der Name identifiziert nichts, es
|
||||
entscheidet die Adresse, die die Quelle meldet. Entscheidet sie nicht, gibt
|
||||
es keinen Treffer — ein eigenes Asset ist eine sichtbare Dublette, das
|
||||
kleinste id zu nehmen hiesse Findings am falschen Host.
|
||||
"""
|
||||
if len(rows) <= 1:
|
||||
return rows[0] if rows else None
|
||||
ip = (ip or "").strip()
|
||||
hits = [a for a in rows if ip and a.ip_address == ip]
|
||||
if len(hits) == 1:
|
||||
return hits[0]
|
||||
logger.info("asset match: '%s' fuehren %d Assets, die Adresse %s entscheidet "
|
||||
"nicht — kein Treffer", what, len(rows), ip or "-")
|
||||
return None
|
||||
|
||||
|
||||
def match_by_hostname(db: Session, pattern: str, *, pin_col, pin_value: Optional[str],
|
||||
ip: Optional[str] = None, not_pinned_by: tuple = ()) -> Optional[Asset]:
|
||||
"""Das Asset, das diesen Namen führt — außer ein anderes Gerät hat ihn auch.
|
||||
|
||||
Dieselbe Stufe wie oben, eine Ebene höher: nach dem stabilen Identifier
|
||||
fällt jede Sync-Match-Kette auf den Hostnamen zurück, und auch der ist
|
||||
kein Identifikator. Ab Werk vergebene Namen ("RackPDU", "Switch") stehen
|
||||
im Bestand mehrfach, weil sie nie geändert wurden — teils, weil das Gerät
|
||||
es gar nicht zulässt.
|
||||
|
||||
`pin_col`/`pin_value` sind die Identitätsspalte DIESER Quelle und der Wert
|
||||
für DIESES Gerät (netdisco_device_ip, nessus_host_uuid, vmware_uuid,
|
||||
igel_unit_id). Trägt ein gleichnamiges Asset dort bereits einen anderen
|
||||
Wert, gehört es einem anderen Gerät derselben Quelle — der Name ist dann
|
||||
ein Namensvetter, kein Treffer. Ein Asset ohne Pin bleibt matchbar: das
|
||||
ist der Fall, für den die Stufe existiert (Wazuh legt an, Netdisco findet
|
||||
dasselbe Gerät über den Namen).
|
||||
|
||||
Kennt die Quelle für dieses Gerät selbst keinen Identifier (`pin_value`
|
||||
leer), gibt es nichts zu unterscheiden — dann matcht der Name wie bisher.
|
||||
|
||||
Bleiben danach mehrere Kandidaten, entscheidet `ip` (pick_one). Ein
|
||||
Muster auf ".%" ist die FQDN-Präfix-Stufe; nur dieses `%` ist Platzhalter.
|
||||
`not_pinned_by`: siehe _not_pinned_by.
|
||||
"""
|
||||
pattern = (pattern or "").strip()
|
||||
if not pattern:
|
||||
return None
|
||||
prefix = pattern.endswith(".%")
|
||||
like = like_literal(pattern[:-1] if prefix else pattern) + ("%" if prefix else "")
|
||||
q = _not_pinned_by(db.query(Asset).filter(Asset.hostname.ilike(like, escape="\\")),
|
||||
not_pinned_by)
|
||||
if pin_value:
|
||||
q = q.filter((pin_col.is_(None)) | (pin_col == pin_value))
|
||||
return pick_one(q.order_by(Asset.id).all(), ip, pattern)
|
||||
|
||||
|
||||
# Serial numbers a device answers when it has none to answer. A placeholder is
|
||||
# not an identity: matching on one would collapse every device that gives the
|
||||
# same non-answer onto a single asset — the wrong-host merge, which is worse
|
||||
# than the fork the serial pin exists to prevent. Netdisco passes the SNMP
|
||||
# value through as it gets it; Intune's Graph payload has the same habit for
|
||||
# devices that report no hardware serial.
|
||||
_SERIAL_PLACEHOLDERS = {"n/a", "na", "none", "null", "unknown", "unspecified",
|
||||
"not available", "not specified", "no serial", "chassis",
|
||||
"system serial number", "to be filled by o.e.m.",
|
||||
"default string"}
|
||||
|
||||
|
||||
def stable_serial(serial: Optional[str]) -> Optional[str]:
|
||||
"""The serial if it identifies a device, None if it is a placeholder."""
|
||||
s = (serial or "").strip()
|
||||
low = s.lower()
|
||||
if len(s) < 4 or low in _SERIAL_PLACEHOLDERS:
|
||||
return None
|
||||
# All zeros, dashes, dots or colons — the other way a device says "none".
|
||||
# MACs stay: Cumulus and friends answer their chassis MAC here, and that
|
||||
# is a perfectly good identifier.
|
||||
if not low.strip("0-.: "):
|
||||
return None
|
||||
return s[:64]
|
||||
@@ -7,8 +7,10 @@ previously the audit history only began with the first status change.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Iterable
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -71,23 +73,98 @@ def audit_new_vulnerabilities(
|
||||
return written
|
||||
|
||||
|
||||
# How far behind reality each source's inventory can be, and therefore how
|
||||
# long after a close its "still vulnerable" claim is not trustworthy.
|
||||
# How far behind reality each inventory can be, and therefore how long after a
|
||||
# close a "still vulnerable" claim read from it is not trustworthy.
|
||||
#
|
||||
# Measured against how the data actually arrives, not against the scan
|
||||
# schedule: Wazuh syscollector is close to live, Intune depends on the
|
||||
# tenant's inventory-refresh policy, and Defender TVM's software list is the
|
||||
# slowest of the three by a wide margin. Sources that read a live inventory
|
||||
# themselves (wazuh_sync, app_scan, msrc, nessus) get no grace — when they say
|
||||
# it is back, it is back.
|
||||
# schedule: Wazuh syscollector is close to live, Defender TVM's software list
|
||||
# trails by days, and Intune detectedApps depends entirely on how aggressively
|
||||
# the tenant's policies push an inventory refresh — a week is not unusual.
|
||||
# A live inventory (wazuh) gets no grace at all: when it says the CVE is back,
|
||||
# it is back, so it is deliberately absent from this table.
|
||||
_INTUNE_LAG = timedelta(days=7)
|
||||
_REOPEN_GRACE = {
|
||||
"defender": timedelta(days=3),
|
||||
"intune": timedelta(days=1),
|
||||
"m365_check": timedelta(days=1), # same Graph inventory as Intune
|
||||
"intune": _INTUNE_LAG,
|
||||
"m365_check": _INTUNE_LAG, # same Graph inventory as Intune
|
||||
"mobile_eol": timedelta(days=1),
|
||||
"android_cve": timedelta(days=1),
|
||||
}
|
||||
|
||||
# Scanners whose verdict is only ever as fresh as the software inventory they
|
||||
# were handed. They do not fetch anything themselves — the app-CVE scan reads
|
||||
# whatever list `run_app_cve_scan` obtained for the asset, and the MSRC
|
||||
# fixed-build pass compares against that same list. Keying their grace on the
|
||||
# scanner name therefore says nothing about how old the data is (field report
|
||||
# 06.09.2026: two Intune+Defender hosts, no Wazuh agent. Defender TVM saw the
|
||||
# Edge update and closed a dozen CVEs at 02:10; at 03:20 the app scan pulled
|
||||
# Intune's detectedApps, which still listed Edge 132.0.6199.32, and the MSRC
|
||||
# pass reopened every one of them — "msrc" carried no grace because it was
|
||||
# assumed to read a live inventory, which on an Intune-only host it does not).
|
||||
_INVENTORY_SOURCES = {"app_scan", "msrc"}
|
||||
|
||||
# Which inventory the scan currently running actually read for this asset.
|
||||
# Ambient rather than a parameter on purpose: a dozen scanners funnel into the
|
||||
# two _upsert functions that reopen, and every one of them would otherwise have
|
||||
# to thread a value through that it never looks at itself.
|
||||
_current_inventory: ContextVar[Optional[str]] = ContextVar(
|
||||
"reopen_inventory", default=None)
|
||||
|
||||
|
||||
def use_inventory(name: Optional[str]) -> None:
|
||||
"""Same declaration as `scanning_inventory`, for a scan loop that sets it
|
||||
once per asset. Callers pass None when they are done or do not know."""
|
||||
_current_inventory.set((name or "").strip().lower() or None)
|
||||
|
||||
|
||||
def asset_inventory(asset) -> Optional[str]:
|
||||
"""The inventory that owns this asset's software list and version fields.
|
||||
|
||||
Wazuh first: a host enrolled in both keeps syscollector as its inventory
|
||||
(Intune's detectedApps is a thinner, differently-named list), which is the
|
||||
same precedence `run_app_cve_scan` uses to pick who to ask.
|
||||
|
||||
Deliberately only those two. A Defender-only asset is never asked for a
|
||||
package list at all, and the 3-day grace under "defender" describes the TVM
|
||||
CVE feed, not the software inventory that follows device telemetry — naming
|
||||
it here would mute reopens for three days on the strength of the wrong
|
||||
number, and an over-granted grace is a false negative.
|
||||
"""
|
||||
try:
|
||||
if getattr(asset, "wazuh_agent_id", None):
|
||||
return "wazuh"
|
||||
if getattr(asset, "intune_device_id", None):
|
||||
return "intune"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def scanning_inventory(name: Optional[str]):
|
||||
"""Declare whose software list the enclosed scan is deciding from.
|
||||
|
||||
`name` is the inventory ("wazuh", "intune", "defender"), NOT the scanner —
|
||||
None when the caller does not know, which leaves the old behaviour.
|
||||
"""
|
||||
token = _current_inventory.set((name or "").strip().lower() or None)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_current_inventory.reset(token)
|
||||
|
||||
|
||||
def _reopen_grace(source: str) -> Optional[timedelta]:
|
||||
"""The longest lag that applies to this claim: the source's own, plus the
|
||||
inventory's when the source is only relaying one."""
|
||||
keys = [(source or "").lower()]
|
||||
if keys[0] in _INVENTORY_SOURCES:
|
||||
inv = _current_inventory.get()
|
||||
if inv:
|
||||
keys.append(inv)
|
||||
lags = [g for g in (_REOPEN_GRACE.get(k) for k in keys) if g]
|
||||
return max(lags) if lags else None
|
||||
|
||||
|
||||
def reopen_if_patched(db: Session, vuln, *, reason: str, source: str) -> bool:
|
||||
"""Flip a patched finding back to OPEN and AUDIT the transition.
|
||||
@@ -116,12 +193,14 @@ def reopen_if_patched(db: Session, vuln, *, reason: str, source: str) -> bool:
|
||||
#
|
||||
# A source may therefore only reopen a finding once its own lag has had
|
||||
# time to pass. Below that, its claim is about a state the other source has
|
||||
# already superseded.
|
||||
grace = _REOPEN_GRACE.get((source or "").lower())
|
||||
# already superseded. "Its own lag" is the lag of the INVENTORY the claim
|
||||
# was read from, not of the scanner that read it — see _INVENTORY_SOURCES.
|
||||
grace = _reopen_grace(source)
|
||||
if grace and vuln.patched_at and (datetime.now() - vuln.patched_at) < grace:
|
||||
logger.debug(
|
||||
"reopen from %s ignored for %s — patched %s ago, within its %s lag",
|
||||
source, vuln.cve_id, datetime.now() - vuln.patched_at, grace)
|
||||
"reopen from %s (inventory %s) ignored for %s — patched %s ago, "
|
||||
"within its %s lag", source, _current_inventory.get() or "unknown",
|
||||
vuln.cve_id, datetime.now() - vuln.patched_at, grace)
|
||||
return False
|
||||
old_status = vuln.status
|
||||
vuln.status = VulnerabilityStatus.open
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Citrix Workspace app for Windows — build identity and the CVE decision.
|
||||
|
||||
Why this module exists
|
||||
----------------------
|
||||
The Windows inventory states one thing about the install: the build.
|
||||
|
||||
Citrix Workspace 2507 Citrix Systems, Inc. 25.7.1000.1025
|
||||
|
||||
It does not say Current Release (CR) or Long Term Service Release (LTSR), and
|
||||
it does not say which Cumulative Update (CU). Citrix's CVE records, in turn,
|
||||
state no build at all — only release names, and in a different spelling on
|
||||
nearly every record:
|
||||
|
||||
CVE-2026-78547 0 lessThan "2603.11 Current Release (CR)"
|
||||
0 lessThan "2507.1 LTSR CU3"
|
||||
0 lessThan "LTSR 2607"
|
||||
CVE-2025-4879 "2402 LTSR" lessThan "CU2 Hotfix 1"
|
||||
CVE-2024-7889 "Current Release (CR)" lessThan "2405"
|
||||
|
||||
Read as digits those bounds are (2603, 11), (2507, 1, 3) and (2607,), and every
|
||||
25.x/26.x build sits below all of them — the fully patched CU3, CR 2603.11 and
|
||||
LTSR 2607 hosts included. Read literally, the three ranges also contradict each
|
||||
other: "0 .. LTSR 2607" swallows 2507.1 CU3, which the same record calls fixed.
|
||||
|
||||
The build catalog
|
||||
-----------------
|
||||
Both sides meet on the BUILD, and Citrix publishes the mapping on its download
|
||||
pages: one page per release, its title naming the release and a "Version:"
|
||||
line naming the build ("Citrix Workspace app for Windows LTSR 2507.1 Cumulative
|
||||
Update 3" / "Version: 25.7.3000.3034"). The catalog is those pairs — nothing is
|
||||
derived from a numbering scheme, a row is only ever what a page says.
|
||||
|
||||
A page row is accepted only if its build AGREES with its title: year.month
|
||||
from the release (2507.1 → 25.7), and the third field from the rest (CR
|
||||
2603.11 → 11, LTSR base 2507.1 → 1, CU3 → 3000, CU2 Hotfix 1 → 2001). That
|
||||
check is not optional: five CR pages (2302 … 2307.1) print a sidebar build,
|
||||
22.12.0.48, as their first "Version:", and without it they would teach the
|
||||
catalog that 22.12.0.48 is CR 2307.1.
|
||||
|
||||
Release lines
|
||||
-------------
|
||||
CR and LTSR never share a year.month (checked on every Windows download page:
|
||||
24.2, 25.7, 26.7 are LTSR-only, there is no CR 2402/2507/2607). So the catalog
|
||||
answers "which branch is this build" from its first two fields, and a build on
|
||||
a year.month no page has named gets no verdict at all.
|
||||
|
||||
The decision then reuses vmware_release_service.is_affected, because the shape
|
||||
is the same one VMware has: one fix PER LINE, several lines per CVE.
|
||||
|
||||
* CR is one line; a build is affected iff it is below the CR fix.
|
||||
* Each LTSR (2402, 2507.1, 2607) is its own line and its CU is the update
|
||||
line. CVE-2025-4879 fixes 2402 in "CU2 Hotfix 1" AND "CU3 Hotfix 1": a CU2
|
||||
HF1 host is patched although CU3 base (a higher build) is not.
|
||||
* A fix on another branch or another LTSR never decides: "before LTSR 2607"
|
||||
says nothing about a 2402 host, and the CR fix nothing about any LTSR.
|
||||
|
||||
A bound that cannot be resolved to exactly one catalog build (lessThan "1",
|
||||
a lessThanOrEqual, a release no page lists) makes the whole CVE undecided —
|
||||
"a missing verdict is recoverable, a wrong one is not".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from app.services import vmware_release_service as vmr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The cvelistV5 registry key.
|
||||
KEY = "citrix-workspace-win"
|
||||
|
||||
CR = "CR"
|
||||
LTSR = "LTSR"
|
||||
|
||||
_DOWNLOADS_URL = "https://www.citrix.com/downloads/workspace-app/"
|
||||
# The four sections that hold Workspace app for WINDOWS, current and legacy.
|
||||
# Everything else under /workspace-app/ is another product (Mac, Linux, the
|
||||
# Enterprise Browser, Desktop Lock) with its own numbering.
|
||||
_WINDOWS_PAGE_RE = re.compile(
|
||||
r'href="(/downloads/workspace-app/(?:windows|legacy-workspace-app-for-windows'
|
||||
r'|workspace-app-for-windows-long-term-service-release'
|
||||
r'|legacy-workspace-app-for-windows-ltsr)/[^"#?]+\.html)"', re.I)
|
||||
_CATALOG_SETTING = "citrix_workspace_build_catalog"
|
||||
_CATALOG_TTL = timedelta(days=7)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Names
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
# "2507.1", "2402", "2603.11" — never a piece of a dotted number, or the
|
||||
# "1001" in "19.12.1001" would read as a release.
|
||||
_RELEASE_RE = re.compile(r"(?<![\d.])(\d{4})(?:\.(\d{1,2}))?(?![\d.])")
|
||||
_CU_RE = re.compile(r"\b(?:cu|cumulative\s+update)\s*(\d+)\b", re.I)
|
||||
_HF_RE = re.compile(r"\b(?:hotfix|hf)\s*(\d+)\b", re.I)
|
||||
_LTSR_RE = re.compile(r"\bltsr\b|long\s+term\s+service", re.I)
|
||||
_CR_RE = re.compile(r"\bcr\b|current\s+release", re.I)
|
||||
_BUILD_RE = re.compile(r"^\s*(\d+)\.(\d+)\.(\d+)\.(\d+)\s*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Name:
|
||||
"""What a release name says, with anything it does not say left None."""
|
||||
branch: Optional[str]
|
||||
release: Optional[str]
|
||||
cu: Optional[int]
|
||||
hotfix: Optional[int]
|
||||
|
||||
|
||||
def parse_name(text: Optional[str]) -> Name:
|
||||
s = text or ""
|
||||
ltsr, cr = bool(_LTSR_RE.search(s)), bool(_CR_RE.search(s))
|
||||
branch = LTSR if ltsr and not cr else CR if cr and not ltsr else None
|
||||
rel = _RELEASE_RE.search(s)
|
||||
cu, hf = _CU_RE.search(s), _HF_RE.search(s)
|
||||
return Name(branch=branch,
|
||||
release=(rel.group(0) if rel else None),
|
||||
cu=(int(cu.group(1)) if cu else None),
|
||||
hotfix=(int(hf.group(1)) if hf else None))
|
||||
|
||||
|
||||
def parse_build(raw: Optional[str]) -> Optional[Tuple[int, int, int, int]]:
|
||||
m = _BUILD_RE.match(raw or "")
|
||||
return tuple(int(x) for x in m.groups()) if m else None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Catalog
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Entry:
|
||||
branch: str
|
||||
release: str
|
||||
cu: int
|
||||
hotfix: int
|
||||
build: Tuple[int, int, int, int]
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
s = f"{self.branch} {self.release}"
|
||||
if self.cu:
|
||||
s += f" CU{self.cu}"
|
||||
if self.hotfix:
|
||||
s += f" Hotfix {self.hotfix}"
|
||||
return s
|
||||
|
||||
|
||||
def entry_from_page(title: str, version: str) -> Optional[Entry]:
|
||||
"""One download page (its title and "Version:") → a catalog entry, or None
|
||||
when the build does not agree with the title (see the module docstring)."""
|
||||
build = parse_build(version)
|
||||
n = parse_name(title)
|
||||
if not build or not n.release:
|
||||
return None
|
||||
# A download page never says "CR"; it says LTSR or nothing.
|
||||
branch = LTSR if n.branch == LTSR else CR
|
||||
cu, hf = n.cu or 0, n.hotfix or 0
|
||||
if branch == CR and (cu or hf):
|
||||
return None
|
||||
yy, mm = int(n.release[:2]), int(n.release[2:4])
|
||||
minor = int(n.release.split(".")[1]) if "." in n.release else 0
|
||||
third = cu * 1000 + hf if cu else minor
|
||||
if build[:3] != (yy, mm, third):
|
||||
return None
|
||||
return Entry(branch=branch, release=n.release, cu=cu, hotfix=hf, build=build)
|
||||
|
||||
|
||||
def compile_rows(rows: Iterable[Sequence[str]]) -> List[Entry]:
|
||||
out: Dict[tuple, Entry] = {}
|
||||
for title, version in rows:
|
||||
e = entry_from_page(title, version)
|
||||
if e:
|
||||
out.setdefault(e.build, e)
|
||||
return list(out.values())
|
||||
|
||||
|
||||
def branch_of(build: Tuple[int, ...], catalog: Sequence[Entry]) -> Optional[str]:
|
||||
"""The branch every catalog build on this year.month belongs to, or None
|
||||
when no page names the line (or, which has never happened, both do)."""
|
||||
found = {e.branch for e in catalog if e.build[:2] == build[:2]}
|
||||
return found.pop() if len(found) == 1 else None
|
||||
|
||||
|
||||
def _release(branch: str, build: Tuple[int, ...], label: str) -> vmr.Release:
|
||||
if branch == CR:
|
||||
return vmr.Release(line=(CR,), update=0, build=build, label=label)
|
||||
return vmr.Release(line=(LTSR, build[0], build[1]), update=build[2] // 1000,
|
||||
build=build, label=label)
|
||||
|
||||
|
||||
def installed_release(version: Optional[str],
|
||||
catalog: Sequence[Entry]) -> Optional[vmr.Release]:
|
||||
build = parse_build(version)
|
||||
if not build:
|
||||
return None
|
||||
branch = branch_of(build, catalog)
|
||||
if not branch:
|
||||
return None
|
||||
exact = next((e for e in catalog if e.build == build), None)
|
||||
return _release(branch, build, exact.label if exact else version.strip())
|
||||
|
||||
|
||||
def bound_release(less_than: Optional[str], version_field: Optional[str],
|
||||
catalog: Sequence[Entry]) -> Optional[vmr.Release]:
|
||||
"""A record's lessThan (+ its version field) → the fixing release, or None.
|
||||
|
||||
The version field is read for the branch ("Current Release (CR)" /
|
||||
"2402 LTSR"), and for the release only when lessThan names a CU or hotfix
|
||||
without one ("CU2 Hotfix 1"). A lessThan with neither — CVE-2024-6286
|
||||
writes "1" — is not a bound and resolves to nothing.
|
||||
"""
|
||||
lt = (less_than or "").strip()
|
||||
if not lt:
|
||||
return None
|
||||
build = parse_build(lt)
|
||||
if build:
|
||||
branch = branch_of(build, catalog)
|
||||
return _release(branch, build, lt) if branch else None
|
||||
|
||||
n, ctx = parse_name(lt), parse_name(version_field)
|
||||
release = n.release
|
||||
if not release and (n.cu is not None or n.hotfix is not None):
|
||||
release = ctx.release
|
||||
if not release:
|
||||
return None
|
||||
branches = {b for b in (n.branch, ctx.branch) if b}
|
||||
if len(branches) > 1:
|
||||
return None
|
||||
branch = branches.pop() if branches else None
|
||||
hits = [e for e in catalog
|
||||
if e.release == release and e.cu == (n.cu or 0)
|
||||
and e.hotfix == (n.hotfix or 0) and (branch is None or e.branch == branch)]
|
||||
if len({e.build for e in hits}) != 1:
|
||||
return None
|
||||
e = hits[0]
|
||||
return _release(e.branch, e.build, f"{'.'.join(map(str, e.build))} ({e.label})")
|
||||
|
||||
|
||||
def decide(installed: vmr.Release, entries: Sequence[dict],
|
||||
catalog: Sequence[Entry]) -> Tuple[Optional[bool], Optional[str]]:
|
||||
"""All index entries of ONE CVE → (affected, fix hint).
|
||||
|
||||
affected is None when any bound of the CVE cannot be resolved: a bound we
|
||||
could not read may be the one on this host's line.
|
||||
"""
|
||||
bounds = []
|
||||
for ent in entries:
|
||||
if not ent.get("lt"):
|
||||
return None, None # lessThanOrEqual only: no fix build named
|
||||
b = bound_release(ent.get("lt"), ent.get("ver"), catalog)
|
||||
if b is None:
|
||||
return None, None
|
||||
bounds.append(b)
|
||||
if not bounds:
|
||||
return None, None
|
||||
if not vmr.is_affected(installed, bounds):
|
||||
return False, None
|
||||
return True, vmr.fix_hint(installed, bounds)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Seed (Citrix download pages, 2026-09-14) and refresh
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
SEED_PAGES: List[Tuple[str, str]] = [
|
||||
('Citrix Workspace app 1912 LTSR for Windows', '19.12.0.119'),
|
||||
('Citrix Workspace app for Windows LTSR 2203.1', '22.3.1.41'),
|
||||
('Citrix Workspace app 22.03.5000 for Windows, LTSR 2203.1 Cumulative Update 5', '22.03.5000.5107'),
|
||||
('Citrix Workspace app 22.03.6002 for Windows, LTSR 2203.1 Cumulative Update 6 Hotfix 2', '22.03.6002.6116'),
|
||||
('Citrix Workspace app 2209 for Windows', '22.9.0.28'),
|
||||
('Citrix Workspace app 2210 for Windows', '22.10.0.21'),
|
||||
('Citrix Workspace app 2210.5 for Windows', '22.10.5.14'),
|
||||
('Citrix Workspace app 2212 for Windows', '22.12.0.48'),
|
||||
('Citrix Workspace app 2309 for Windows', '23.9.0.99'),
|
||||
('Citrix Workspace app 2309.1 for Windows', '23.9.1.104'),
|
||||
('Citrix Workspace app 2311.1 for Windows', '23.11.1.140'),
|
||||
('Citrix Workspace app LTSR 2402 for Windows', '24.2.0.172'),
|
||||
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 1', '24.2.1000.1016'),
|
||||
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 1 Hotfix 1 - 24.02.1001', '24.2.1001.2'),
|
||||
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 1 Hotfix 3 - 24.02.1003', '24.2.1003.3'),
|
||||
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 2 Hotfix 1', '24.2.2001.3'),
|
||||
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 3 Hotfix 1', '24.2.3001.9'),
|
||||
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 4 Hotfix 1', '24.2.4001.1'),
|
||||
('Citrix Workspace app 2403 for Windows', '24.3.0.93'),
|
||||
('Citrix Workspace app 2403.1 for Windows', '24.3.1.97'),
|
||||
('Citrix Workspace app 2405 for Windows', '24.5.0.131'),
|
||||
('Citrix Workspace app 2405.10 for Windows', '24.5.10.29'),
|
||||
('Citrix Workspace app 2405.11 for Windows', '24.5.11.31'),
|
||||
('Citrix Workspace app 2405.12 for Windows', '24.5.12.42'),
|
||||
('Citrix Workspace app 2409 for Windows', '24.9.0.201'),
|
||||
('Citrix Workspace app 2409.1 for Windows', '24.9.1.207'),
|
||||
('Citrix Workspace app 2409.10 for Windows', '24.9.10.28'),
|
||||
('Citrix Workspace app 2503.1 for Windows', '25.3.1.194'),
|
||||
('Citrix Workspace app 2503.2 for Windows', '25.3.2.196'),
|
||||
('Citrix Workspace app 2503.10 for Windows', '25.3.10.69'),
|
||||
('Citrix Workspace app LTSR 2507.1 for Windows', '25.7.1.9'),
|
||||
('Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 1', '25.7.1000.1025'),
|
||||
('Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 2', '25.7.2000.2020'),
|
||||
('Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 3', '25.7.3000.3034'),
|
||||
('Citrix Workspace app 2508 for Windows', '25.8.0.71'),
|
||||
('Citrix Workspace app 2508.10 for Windows', '25.8.10.36'),
|
||||
('Citrix Workspace app 2511 for Windows', '25.11.0.200'),
|
||||
('Citrix Workspace app 2511.1 for Windows', '25.11.1.209'),
|
||||
('Citrix Workspace app 2511.10 for Windows', '25.11.10.50'),
|
||||
('Citrix Workspace app 2603 for Windows', '26.3.0.188'),
|
||||
('Citrix Workspace app 2603.1 for Windows', '26.3.1.194'),
|
||||
('Citrix Workspace app 2603.10 for Windows', '26.3.10.69'),
|
||||
('Citrix Workspace app 2603.11 for Windows', '26.3.11.10'),
|
||||
('Citrix Workspace app for Windows LTSR 2607', '26.7.0.269'),
|
||||
]
|
||||
|
||||
_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S | re.I)
|
||||
_PAGE_VERSION_RE = re.compile(r"Version\s*:\s*([\d.]+)")
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def parse_page(page_html: str) -> Optional[Tuple[str, str]]:
|
||||
"""A download page → (title, first "Version:" after the title)."""
|
||||
m = _H1_RE.search(page_html or "")
|
||||
if not m:
|
||||
return None
|
||||
title = _html.unescape(_TAG_RE.sub("", m.group(1))).strip()
|
||||
v = _PAGE_VERSION_RE.search(page_html, m.end())
|
||||
return (title, v.group(1).rstrip(".")) if v else None
|
||||
|
||||
|
||||
def _fetch_pages() -> List[Tuple[str, str]]:
|
||||
import httpx
|
||||
rows: List[Tuple[str, str]] = []
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True,
|
||||
headers={"User-Agent": "Mozilla/5.0 (TrueVuln)"}) as c:
|
||||
r = c.get(_DOWNLOADS_URL)
|
||||
r.raise_for_status()
|
||||
links = sorted(set(_WINDOWS_PAGE_RE.findall(r.text)))
|
||||
for path in links:
|
||||
try:
|
||||
p = c.get("https://www.citrix.com" + path)
|
||||
p.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.debug("citrix: %s fetch failed: %s", path, e)
|
||||
continue
|
||||
row = parse_page(p.text)
|
||||
if row:
|
||||
rows.append(row)
|
||||
valid = len(compile_rows(rows))
|
||||
if valid < 20:
|
||||
raise ValueError(f"only {valid} valid build rows from {len(links)} pages — layout changed?")
|
||||
return rows
|
||||
|
||||
|
||||
def refresh_catalog(db) -> List[Entry]:
|
||||
"""Re-read the download pages; merged over cache and seed, never replacing
|
||||
them, so an outage or layout change can only fail to ADD builds."""
|
||||
from app.models.setting import Setting
|
||||
row = db.query(Setting).filter(Setting.key == _CATALOG_SETTING).first()
|
||||
rows = list(SEED_PAGES) + _cached_rows(row)
|
||||
try:
|
||||
fetched = _fetch_pages()
|
||||
rows += fetched
|
||||
logger.info("citrix: Workspace app build catalog refreshed (%d pages)", len(fetched))
|
||||
except Exception as e:
|
||||
logger.warning("citrix: build catalog refresh failed (%s) — keeping cached/seed", e)
|
||||
rows = sorted({(t, v) for t, v in rows})
|
||||
payload = json.dumps({"fetched_at": datetime.now().isoformat(), "rows": rows})
|
||||
if row:
|
||||
row.value = payload
|
||||
else:
|
||||
db.add(Setting(key=_CATALOG_SETTING, value=payload,
|
||||
description="Citrix Workspace app for Windows: release → build (download pages)"))
|
||||
db.commit()
|
||||
_MEMO.clear()
|
||||
return compile_rows(rows)
|
||||
|
||||
|
||||
def _cached_rows(row) -> List[Tuple[str, str]]:
|
||||
if not row or not row.value:
|
||||
return []
|
||||
try:
|
||||
return [tuple(r) for r in json.loads(row.value).get("rows") or []]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
_MEMO: Dict[str, List[Entry]] = {}
|
||||
|
||||
|
||||
def load_catalog(db) -> List[Entry]:
|
||||
"""Seed plus whatever the last refresh stored, at any age: an old catalog
|
||||
only lacks the newest releases, and those get no verdict until it learns
|
||||
them."""
|
||||
if "catalog" not in _MEMO:
|
||||
from app.models.setting import Setting
|
||||
row = db.query(Setting).filter(Setting.key == _CATALOG_SETTING).first()
|
||||
_MEMO["catalog"] = compile_rows(list(SEED_PAGES) + _cached_rows(row))
|
||||
return _MEMO["catalog"]
|
||||
|
||||
|
||||
def build_index(db) -> None:
|
||||
"""Nightly hook (scheduler): refresh the catalog once it is a week old."""
|
||||
from app.models.setting import Setting
|
||||
row = db.query(Setting).filter(Setting.key == _CATALOG_SETTING).first()
|
||||
try:
|
||||
fresh = row and datetime.now() - datetime.fromisoformat(
|
||||
json.loads(row.value)["fetched_at"]) <= _CATALOG_TTL
|
||||
except Exception:
|
||||
fresh = False
|
||||
if not fresh:
|
||||
refresh_catalog(db)
|
||||
@@ -180,8 +180,13 @@ def refresh_asset_compliance(
|
||||
return stats
|
||||
|
||||
|
||||
def refresh_all_compliance(db: Session) -> Dict[str, Any]:
|
||||
"""Loop over every Wazuh-linked asset and refresh its SCA results."""
|
||||
def refresh_all_compliance(db: Session, trigger: str = "manual") -> Dict[str, Any]:
|
||||
"""Loop over every Wazuh-linked asset and refresh its SCA results.
|
||||
|
||||
A refused login is recorded as a failed Wazuh run (mail, cooldown) —
|
||||
otherwise it is one "wazuh API:" error per asset and a job that
|
||||
completed."""
|
||||
from app.services.sync_run_service import probe_wazuh_api, report_source_failure, source_failure
|
||||
client = _load_wazuh_client(db)
|
||||
if client is None:
|
||||
return {
|
||||
@@ -189,6 +194,13 @@ def refresh_all_compliance(db: Session) -> Dict[str, Any]:
|
||||
"policies_synced": 0,
|
||||
"errors": ["wazuh_config not set"],
|
||||
}
|
||||
err = probe_wazuh_api(client, phase="sca-refresh", trigger=trigger)
|
||||
if err:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"assets_synced": 0, "policies_synced": 0, "errors": [f"wazuh API: {err}"]}
|
||||
|
||||
# No status filter — Wazuh keeps SCA results for disconnected agents
|
||||
# in the indexer too, so we let the API decide what's available.
|
||||
@@ -223,6 +235,9 @@ def refresh_all_compliance(db: Session) -> Dict[str, Any]:
|
||||
"compliance: refreshed %d assets, %d policy results, %d errors",
|
||||
overall["assets_synced"], overall["policies_synced"], len(overall["errors"]),
|
||||
)
|
||||
hit = source_failure(e for e in overall["errors"] if str(e).startswith("wazuh API:"))
|
||||
if hit:
|
||||
report_source_failure("wazuh", trigger, "sca-refresh", hit)
|
||||
return overall
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Container image scan import — Trivy / Grype JSON pushed from a Docker host.
|
||||
|
||||
One asset per image (normalised `repo:tag`), source CONTAINER. Every container
|
||||
running that image shares the asset, so a base-image CVE counts once per image,
|
||||
not once per container. Findings merge on (cve_id, asset_id) like every other
|
||||
scanner; `sources` carries "trivy" and/or "grype".
|
||||
|
||||
Noise control, in order of effect:
|
||||
1. Baseline: the first report of an image REPOSITORY is imported with
|
||||
`baseline` set and sends no new-finding mail. Later reports — a new tag of
|
||||
the same repository included, so an upgrade that brings a CVE in does
|
||||
alert — notify for findings new on that image. KEV alerts need nothing
|
||||
here: kev_alert_service fires when a catalog flips on an open finding.
|
||||
2. only_fixed (default on): no fix published → not imported.
|
||||
3. min_severity (default high): below it → not imported.
|
||||
Neither filter applies to a CVE on a known-exploited list (CISA KEV, ENISA
|
||||
EUVD, VulnCheck KEV — kev_service): actively exploited is never noise, fix
|
||||
or no fix, whatever the scanner's label. Enrichment then sets kev_listed,
|
||||
which drives the priority score and the KEV alert mail.
|
||||
4. Alias resolution: a GHSA / distro advisory is stored under its CVE, so
|
||||
Trivy and Grype merge instead of counting the same bug twice. Only the ids
|
||||
the report itself names are used — no lookup, no guessing.
|
||||
Accepted risks: mark the finding false-positive / accepted-risk in the UI;
|
||||
the import neither reopens nor closes those. Not .trivyignore: an ignored CVE
|
||||
drops out of the report and would be closed as patched.
|
||||
|
||||
Retraction follows the cross-source contract: a scanner drops only its own
|
||||
source, and only for CVEs absent from its WHOLE report — a CVE the ingest
|
||||
filter hid is still there and must not close. A report with no findings at
|
||||
all retracts nothing (a scanner that did not recognise the distro looks the
|
||||
same as a clean image), as in nessus_sync. An image that is no longer
|
||||
pushed goes INACTIVE through the time-based asset reconcile.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus
|
||||
from app.models.vulnerability import (
|
||||
Vulnerability,
|
||||
VulnerabilitySeverity,
|
||||
VulnerabilityStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CVE_ID = re.compile(r"^CVE-\d{4}-\d{4,}$", re.I)
|
||||
_RANK = {"none": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
|
||||
_LABEL = {"negligible": "low", "moderate": "medium", "important": "high", "unknown": "none"}
|
||||
|
||||
|
||||
def normalise_image(ref: str) -> str:
|
||||
"""`docker.io/library/nginx` and `nginx:latest` are the same image."""
|
||||
ref = (ref or "").strip()
|
||||
for prefix in ("docker.io/library/", "index.docker.io/library/", "docker.io/"):
|
||||
if ref.startswith(prefix):
|
||||
ref = ref[len(prefix):]
|
||||
break
|
||||
if "@" not in ref and ":" not in ref.rsplit("/", 1)[-1]:
|
||||
ref += ":latest"
|
||||
return ref
|
||||
|
||||
|
||||
def _repository(image: str) -> str:
|
||||
"""`nginx:1.25` → `nginx`; `registry:5000/app@sha256:…` → `registry:5000/app`."""
|
||||
ref = image.split("@", 1)[0]
|
||||
head, _, last = ref.rpartition("/")
|
||||
return f"{head}/{last.split(':', 1)[0]}" if head else last.split(":", 1)[0]
|
||||
|
||||
|
||||
def severity_band(label: Optional[str], cvss: Optional[float]) -> str:
|
||||
sev = str(label or "").strip().lower()
|
||||
sev = _LABEL.get(sev, sev)
|
||||
if sev not in _RANK:
|
||||
sev = "none"
|
||||
if sev == "none" and cvss is not None:
|
||||
sev = ("critical" if cvss >= 9 else "high" if cvss >= 7
|
||||
else "medium" if cvss >= 4 else "low" if cvss > 0 else "none")
|
||||
return sev
|
||||
|
||||
|
||||
def _cve_ids(primary: str, aliases: List[str]) -> List[str]:
|
||||
"""The CVE(s) a finding is about. A CVE id stays itself; an advisory that
|
||||
names CVEs becomes those CVEs (one advisory can cover several); anything
|
||||
else keeps its own id."""
|
||||
if CVE_ID.match(primary or ""):
|
||||
return [primary.upper()]
|
||||
cves = sorted({a.upper() for a in aliases if CVE_ID.match(a or "")})
|
||||
return cves or [primary]
|
||||
|
||||
|
||||
def _grype_score(cvss: list) -> Optional[float]:
|
||||
entries = sorted(cvss or [], key=lambda c: c.get("type") != "Primary")
|
||||
for c in entries:
|
||||
if str(c.get("version", "")).startswith("3"):
|
||||
score = (c.get("metrics") or {}).get("baseScore")
|
||||
if score is not None:
|
||||
return float(score)
|
||||
return None
|
||||
|
||||
|
||||
def parse_report(report: dict) -> Tuple[str, str, Optional[str], Optional[str], List[dict]]:
|
||||
"""→ (scanner, image, digest, os, entries). Each entry is one package hit:
|
||||
{ids, severity, cvss, fixed, package, version, fixed_version, title, description, url}.
|
||||
Raises ValueError for anything that is not an image report."""
|
||||
if not isinstance(report, dict):
|
||||
raise ValueError("report must be a JSON object")
|
||||
|
||||
if "SchemaVersion" in report or "ArtifactName" in report:
|
||||
if report.get("ArtifactType") != "container_image":
|
||||
raise ValueError(f"Trivy report is not an image scan (ArtifactType={report.get('ArtifactType')!r})")
|
||||
meta = report.get("Metadata") or {}
|
||||
os_ = meta.get("OS") or {}
|
||||
entries = []
|
||||
for result in report.get("Results") or []:
|
||||
for v in result.get("Vulnerabilities") or []:
|
||||
scores = v.get("CVSS") or {}
|
||||
score = next((s.get("V3Score") for s in
|
||||
[scores.get("nvd") or {}, *scores.values()] if s.get("V3Score") is not None), None)
|
||||
entries.append({
|
||||
"ids": _cve_ids(v.get("VulnerabilityID", ""), v.get("VendorIDs") or []),
|
||||
"severity": severity_band(v.get("Severity"), score),
|
||||
"cvss": score,
|
||||
"fixed": bool(v.get("FixedVersion")),
|
||||
"package": v.get("PkgName"),
|
||||
"version": v.get("InstalledVersion"),
|
||||
"fixed_version": v.get("FixedVersion"),
|
||||
"title": v.get("Title"),
|
||||
"description": v.get("Description"),
|
||||
"url": v.get("PrimaryURL"),
|
||||
})
|
||||
return ("trivy", normalise_image(report.get("ArtifactName")),
|
||||
next(iter(meta.get("RepoDigests") or []), None),
|
||||
" ".join(filter(None, [os_.get("Family"), os_.get("Name")])) or None,
|
||||
entries)
|
||||
|
||||
if "matches" in report and "source" in report:
|
||||
source = report.get("source") or {}
|
||||
if source.get("type") != "image":
|
||||
raise ValueError(f"Grype report is not an image scan (source.type={source.get('type')!r})")
|
||||
target = source.get("target") or {}
|
||||
distro = report.get("distro") or {}
|
||||
entries = []
|
||||
for m in report.get("matches") or []:
|
||||
v = m.get("vulnerability") or {}
|
||||
related = m.get("relatedVulnerabilities") or []
|
||||
fix = v.get("fix") or {}
|
||||
ids = _cve_ids(v.get("id", ""), [r.get("id") for r in related])
|
||||
for cve in ids:
|
||||
rel = next((r for r in related if (r.get("id") or "").upper() == cve), {})
|
||||
score = _grype_score(rel.get("cvss")) or _grype_score(v.get("cvss"))
|
||||
entries.append({
|
||||
"ids": [cve],
|
||||
"severity": severity_band(v.get("severity"), score),
|
||||
"cvss": score,
|
||||
"fixed": fix.get("state") == "fixed" and bool(fix.get("versions")),
|
||||
"package": (m.get("artifact") or {}).get("name"),
|
||||
"version": (m.get("artifact") or {}).get("version"),
|
||||
"fixed_version": ", ".join(fix.get("versions") or []) or None,
|
||||
"title": None,
|
||||
"description": rel.get("description") or v.get("description"),
|
||||
"url": v.get("dataSource"),
|
||||
})
|
||||
return ("grype", normalise_image(target.get("userInput")),
|
||||
next(iter(target.get("repoDigests") or []), None),
|
||||
" ".join(filter(None, [distro.get("name"), distro.get("version")])) or None,
|
||||
entries)
|
||||
|
||||
raise ValueError("not a Trivy (--format json) or Grype (-o json) report")
|
||||
|
||||
|
||||
def _exploited(db: Session) -> set:
|
||||
"""CVE ids on any KEV catalog. Empty when the catalogs are unavailable —
|
||||
the filter then applies as configured, and the failure is logged."""
|
||||
from app.services.kev_service import get_kev_catalog
|
||||
try:
|
||||
return set(get_kev_catalog(db))
|
||||
except Exception as e:
|
||||
logger.warning("container import: KEV catalog unavailable, filtering without it: %s", e)
|
||||
return set()
|
||||
|
||||
|
||||
def _enrich(db: Session, vulns: list) -> None:
|
||||
from app.services.enrichment_service import enrich_vulnerabilities
|
||||
enrich_vulnerabilities(db, vulns, use_nvd_dates=False)
|
||||
|
||||
|
||||
def _notify(db: Session, vulns: list) -> dict:
|
||||
from app.services.email_service import dispatch_new_vuln_notifications
|
||||
return dispatch_new_vuln_notifications(db, vulns)
|
||||
|
||||
|
||||
def ingest_report(db: Session, report: dict, *, min_severity: str = "high",
|
||||
only_fixed: bool = True) -> dict:
|
||||
"""Import one Trivy / Grype image report. Commits. Raises ValueError on a bad report."""
|
||||
scanner, image, digest, os_name, entries = parse_report(report)
|
||||
return ingest_image(db, scanner, image, entries, digest=digest, os_name=os_name,
|
||||
min_severity=min_severity, only_fixed=only_fixed)
|
||||
|
||||
|
||||
def ingest_image(db: Session, scanner: str, image: str, entries: List[dict], *,
|
||||
digest: Optional[str] = None, os_name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
min_severity: str = "high", only_fixed: bool = True) -> dict:
|
||||
"""Import every finding one source reports for one image — `entries` in the
|
||||
shape parse_report returns, and ALL of them: the unfiltered set is what
|
||||
retraction compares against. `description` replaces the digest line (the
|
||||
Dockhand pull says where the image runs). Commits."""
|
||||
from app.routers.vulnerabilities import log_vulnerability_change
|
||||
from app.services.asset_lifecycle import apply_status, created
|
||||
from app.services.asset_matching import like_literal
|
||||
from app.services.audit_events import (
|
||||
audit_new_vulnerabilities, record_affected_package, reopen_if_patched,
|
||||
)
|
||||
|
||||
floor = _RANK.get(min_severity.lower())
|
||||
if floor is None:
|
||||
raise ValueError(f"min_severity must be one of {list(_RANK)}")
|
||||
if not image:
|
||||
raise ValueError("report names no image")
|
||||
|
||||
stats = {"scanner": scanner, "image": image, "baseline": False, "findings_reported": 0,
|
||||
"filtered_severity": 0, "filtered_unfixed": 0, "kev_kept": 0, "vulns_created": 0,
|
||||
"vulns_merged": 0, "vulns_closed": 0, "notifications": {}}
|
||||
now = datetime.now()
|
||||
|
||||
asset = (db.query(Asset)
|
||||
.filter(Asset.source == AssetSource.CONTAINER, Asset.hostname == image)
|
||||
.first())
|
||||
if asset is None:
|
||||
repo = like_literal(_repository(image))
|
||||
stats["baseline"] = not (db.query(Asset.id)
|
||||
.filter(Asset.source == AssetSource.CONTAINER,
|
||||
Asset.hostname.like(f"{repo}:%", escape="\\")
|
||||
| Asset.hostname.like(f"{repo}@%", escape="\\"))
|
||||
.first())
|
||||
asset = Asset(hostname=image, source=AssetSource.CONTAINER, status=AssetStatus.ACTIVE)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
created(db, asset, scanner)
|
||||
else:
|
||||
apply_status(db, asset, AssetStatus.ACTIVE, f"{scanner} image report received")
|
||||
asset.operating_system = os_name or asset.operating_system
|
||||
if description or digest:
|
||||
asset.description = description or f"Container image, digest {digest}"
|
||||
asset.last_scan = asset.last_seen = now
|
||||
asset.last_seen_source = scanner
|
||||
|
||||
# Everything the scanner reports, filtered or not — the retraction set.
|
||||
reported = {cve for e in entries for cve in e["ids"]}
|
||||
stats["findings_reported"] = len(reported)
|
||||
|
||||
exploited = _exploited(db)
|
||||
kept: Dict[str, dict] = {}
|
||||
for e in entries:
|
||||
if any(cve in exploited for cve in e["ids"]):
|
||||
stats["kev_kept"] += int(_RANK[e["severity"]] < floor or (only_fixed and not e["fixed"]))
|
||||
elif _RANK[e["severity"]] < floor:
|
||||
stats["filtered_severity"] += 1
|
||||
continue
|
||||
elif only_fixed and not e["fixed"]:
|
||||
stats["filtered_unfixed"] += 1
|
||||
continue
|
||||
for cve in e["ids"]:
|
||||
agg = kept.setdefault(cve, {**e, "packages": []})
|
||||
if _RANK[e["severity"]] > _RANK[agg["severity"]]:
|
||||
agg["severity"] = e["severity"]
|
||||
if e["cvss"] is not None and (agg["cvss"] is None or e["cvss"] > agg["cvss"]):
|
||||
agg["cvss"] = e["cvss"]
|
||||
agg["packages"].append(e)
|
||||
|
||||
new_vulns = []
|
||||
for cve, f in kept.items():
|
||||
pkg = f["packages"][0]
|
||||
vuln = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.cve_id == cve[:50], Vulnerability.asset_id == asset.id)
|
||||
.first())
|
||||
if vuln is not None:
|
||||
vuln.add_source(scanner)
|
||||
if vuln.cvss_score is None and f["cvss"] is not None:
|
||||
vuln.cvss_score = f["cvss"]
|
||||
vuln.fixed_version = vuln.fixed_version or pkg["fixed_version"]
|
||||
if f["url"]:
|
||||
vuln.add_reference(f["url"])
|
||||
reopen_if_patched(db, vuln, reason=f"{scanner} reports this CVE in {image} again",
|
||||
source=scanner)
|
||||
stats["vulns_merged"] += 1
|
||||
else:
|
||||
vuln = Vulnerability(
|
||||
cve_id=cve[:50], asset_id=asset.id, cvss_score=f["cvss"],
|
||||
severity=VulnerabilitySeverity(f["severity"]), status=VulnerabilityStatus.open,
|
||||
title=(f["title"] or None) and f["title"][:500], description=f["description"],
|
||||
package_name=(pkg["package"] or "")[:255] or None,
|
||||
package_version=(pkg["version"] or "")[:100] or None,
|
||||
fixed_version=(pkg["fixed_version"] or "")[:100] or None,
|
||||
references=json.dumps([f["url"]]) if f["url"] else None,
|
||||
detected_at=now, sources=json.dumps([scanner]), first_detected_by=scanner,
|
||||
baseline=stats["baseline"],
|
||||
)
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.add(vuln)
|
||||
db.flush()
|
||||
except IntegrityError:
|
||||
logger.debug("container import: duplicate insert race for %s on %s", cve, image)
|
||||
continue
|
||||
try:
|
||||
from app.services.vuln_override_service import apply_canonical_from_siblings
|
||||
apply_canonical_from_siblings(db, vuln)
|
||||
except Exception as e:
|
||||
logger.warning("canonical-inherit on container vuln %s failed: %s", cve, e)
|
||||
new_vulns.append(vuln)
|
||||
stats["vulns_created"] += 1
|
||||
for p in f["packages"]:
|
||||
record_affected_package(db, vuln, name=p["package"], version=p["version"],
|
||||
fixed_version=p["fixed_version"], source=scanner)
|
||||
vuln.refresh_scores()
|
||||
|
||||
stale = (db.query(Vulnerability)
|
||||
.filter(Vulnerability.asset_id == asset.id,
|
||||
Vulnerability.status == VulnerabilityStatus.open,
|
||||
Vulnerability.sources.contains(f'"{scanner}"'))
|
||||
.all()) if reported else []
|
||||
stats["retraction_skipped"] = not reported
|
||||
for v in stale:
|
||||
if v.cve_id in reported:
|
||||
continue
|
||||
v.remove_source(scanner)
|
||||
if not v.source_list:
|
||||
v.status = VulnerabilityStatus.patched
|
||||
v.patched_at = now
|
||||
stats["vulns_closed"] += 1
|
||||
log_vulnerability_change(
|
||||
db, None, v.id, VulnerabilityStatus.open, v.status,
|
||||
reason=f"{scanner} no longer reports this CVE in {image}",
|
||||
cve_id=v.cve_id, source=scanner, hostname=image,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if new_vulns:
|
||||
audit_new_vulnerabilities(db, [v.id for v in new_vulns], source=scanner)
|
||||
db.commit()
|
||||
try:
|
||||
_enrich(db, new_vulns)
|
||||
except Exception as e:
|
||||
logger.warning("container import: enrichment failed (non-fatal): %s", e)
|
||||
if not stats["baseline"]:
|
||||
stats["notifications"] = _notify(db, new_vulns)
|
||||
|
||||
logger.info("container import %s", stats)
|
||||
return stats
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset
|
||||
from app.services.asset_matching import like_literal, pick_one
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,13 +92,15 @@ def _match_asset(db: Session, machine: dict):
|
||||
_pin(a)
|
||||
return a
|
||||
short = dns.split(".")[0] if dns else ""
|
||||
for cand in [c for c in (dns, short) if c]:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(cand)).first()
|
||||
if a:
|
||||
_pin(a)
|
||||
return a
|
||||
if short:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
|
||||
# Not guarded on the machine id (see README.DEV, "What identifies a
|
||||
# device"), but not a guess either: Intune keeps same-named devices as
|
||||
# separate assets, and when the name is carried by several the machine's
|
||||
# address decides — or nothing matches.
|
||||
ip = machine.get("lastIpAddress")
|
||||
for like in [like_literal(c) for c in (dns, short) if c] + \
|
||||
([like_literal(short) + ".%"] if short else []):
|
||||
a = pick_one(db.query(Asset).filter(Asset.hostname.ilike(like, escape="\\"))
|
||||
.order_by(Asset.id).all(), ip, dns)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a
|
||||
@@ -105,7 +108,7 @@ def _match_asset(db: Session, machine: dict):
|
||||
|
||||
|
||||
def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optional[str] = None,
|
||||
vendor: Optional[str] = None) -> None:
|
||||
vendor: Optional[str] = None, os_fallback: bool = True) -> None:
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
cve_id = (vuln.get("id") or "").strip().upper()
|
||||
if not cve_id.startswith("CVE-"):
|
||||
@@ -127,11 +130,28 @@ def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optiona
|
||||
# missing entirely — those rows showed an empty package and an empty
|
||||
# "Installed", which reads as "we know nothing" when the asset record has
|
||||
# had the OS and its version all along (seen: CVE-2026-64726 on iPhones).
|
||||
label = software or (asset.operating_system or None)
|
||||
installed = asset.os_version if not software else None
|
||||
# Only for a device the software export does not cover at all, though: on a
|
||||
# covered one a missing label means the export has not caught up with a CVE
|
||||
# published that day, and the fallback labelled Chrome and Edge CVEs
|
||||
# "Microsoft Windows" with the Windows build (seen: CVE-2026-87474).
|
||||
label = software or (asset.operating_system if os_fallback else None) or None
|
||||
installed = asset.os_version if not software and os_fallback else None
|
||||
|
||||
if existing:
|
||||
existing.add_source(SOURCE_NAME)
|
||||
# Replace that OS placeholder with the export's own label once it has
|
||||
# one — fill-only kept it for good. Whatever Defender names, OS or not
|
||||
# ("microsoft windows_defender 1.1.26070.7"), is more exact than the
|
||||
# asset's OS string. Only Defender's own placeholder, and not while
|
||||
# MSRC claims the row: its OS reconcile finds a finding by the asset's
|
||||
# OS string, so renaming it would leave MSRC no way to close it.
|
||||
if (software and existing.first_detected_by == SOURCE_NAME
|
||||
and asset.operating_system
|
||||
and existing.package_name == asset.operating_system[:255]
|
||||
and "msrc" not in existing.source_list):
|
||||
existing.package_name = software[:255]
|
||||
existing.package_version = None
|
||||
existing.package_vendor = vendor[:255] if vendor else None
|
||||
# Fill the affected-software/package column if it was empty.
|
||||
if label and not existing.package_name:
|
||||
existing.package_name = label[:255]
|
||||
@@ -230,10 +250,13 @@ def run_defender_sync(db: Session) -> dict:
|
||||
# (machineId, CVE) → affected software string. One tenant-wide export
|
||||
# call; the per-machine /vulnerabilities endpoint omits software.
|
||||
sw_map: dict = {}
|
||||
covered: set = set() # machines the export reports on at all
|
||||
try:
|
||||
for r in client.get_software_vulnerabilities_by_machine():
|
||||
mid = (r.get("deviceId") or r.get("machineId") or "").strip()
|
||||
cve = (r.get("cveId") or "").strip().upper()
|
||||
if mid:
|
||||
covered.add(mid)
|
||||
if not mid or not cve:
|
||||
continue
|
||||
vendor = (r.get("softwareVendor") or r.get("productVendor") or "").strip()
|
||||
@@ -278,7 +301,10 @@ def run_defender_sync(db: Session) -> dict:
|
||||
seen_cves.add(cve)
|
||||
sw = sw_map.get((m.get("id", ""), cve)) or {}
|
||||
_upsert_cve(db, asset, v, new_ids,
|
||||
software=sw.get("label"), vendor=sw.get("vendor"))
|
||||
software=sw.get("label"), vendor=sw.get("vendor"),
|
||||
# An empty export (outage, no licence) covers no
|
||||
# device, so it says nothing about any of them.
|
||||
os_fallback=bool(covered) and m.get("id", "") not in covered)
|
||||
stats["cve_rows"] += 1
|
||||
# Collect, resolve later. Several Defender machines can map to ONE
|
||||
# asset — a re-imaged or dual-registered device keeps its old
|
||||
@@ -301,6 +327,10 @@ def run_defender_sync(db: Session) -> dict:
|
||||
pkgs = inv_map.get(m.get("id", ""))
|
||||
if pkgs:
|
||||
from app.services.intune_service import _run_app_inventory
|
||||
# No `inventory=`: this list follows the device's telemetry
|
||||
# (see above), so it is not one of the lagging ones — and the
|
||||
# 3-day grace on "defender" belongs to the TVM CVE feed, not
|
||||
# here. None also clears whatever the enclosing loop declared.
|
||||
stats["app_findings"] = stats.get("app_findings", 0) + \
|
||||
_run_app_inventory(db, asset, pkgs)
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Dockhand → container image findings.
|
||||
|
||||
Pulls every managed environment's vulnerability export and feeds it, image by
|
||||
image, through container_scan_service.ingest_image — the same assets
|
||||
(CONTAINER, one per normalised repo:tag), baseline, filter and retraction as
|
||||
the Trivy / Grype push, under the source name "dockhand".
|
||||
|
||||
What the export does not carry, and what that costs:
|
||||
* no CVSS — severity is Dockhand's label until enrichment / the override
|
||||
cascade fills the score;
|
||||
* no aliases — Grype reports GHSA ids unless Dockhand runs it with
|
||||
`--by-cve` (Settings → General → Default Grype arguments:
|
||||
`-o json --by-cve -v {image}`), and a GHSA id does not merge with the CVE
|
||||
another source reports;
|
||||
* no clean images — an image without findings is not in the export, so it
|
||||
cannot be told from one Dockhand never scanned. Such an image is left
|
||||
alone; if it is gone from the host it goes INACTIVE via the time-based
|
||||
asset reconcile, but a rebuild that fixed EVERYTHING keeps its old findings
|
||||
until then. A known exception to "every detection closes itself";
|
||||
* the latest scan per image AND scanner — after switching Dockhand from
|
||||
Grype to Trivy, the last Grype scan of an image still on the host stays in
|
||||
the export, and what it found cannot close until that image is gone.
|
||||
|
||||
Freshness: Dockhand scans only on pull, update or by hand, so the export ages
|
||||
with the image. With `scan_nightly` set, the nightly sync first has Dockhand
|
||||
rescan every environment (scan-all). A failed rescan does not abort: the
|
||||
export then still holds the previous scan, which is what a sync without the
|
||||
rescan would import anyway.
|
||||
|
||||
Retraction runs per image over the union of ALL environments: the same image
|
||||
in two environments is one asset, and env A must not close what env B still
|
||||
reports. For the same reason one failing environment aborts the whole sync.
|
||||
Which hosts run the image, as which containers, is in the asset description,
|
||||
and — where the Docker host's hostname names exactly one host asset — in
|
||||
container_placements, which links the image to that host.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.integrations.dockhand_client import DockhandClient, DockhandError
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus, ContainerPlacement
|
||||
from app.services import container_scan_service as cs
|
||||
from app.services.asset_matching import different_hosts, like_literal, pick_one
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SETTING_KEY = "dockhand_config"
|
||||
SOURCE_NAME = "dockhand"
|
||||
_SYNC_ADVISORY_LOCK_KEY = 0x54560105 # "TV" + 05
|
||||
_BARE_DIGEST = re.compile(r"^(sha256:)?[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def load_dockhand_config(db: Session) -> Optional[dict]:
|
||||
"""Decrypt + parse dockhand_config, or None when not configured (no base_url).
|
||||
The token is optional: a Dockhand with auth disabled answers without one."""
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
raw = read_setting_value(db, SETTING_KEY)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("dockhand_config is not valid JSON")
|
||||
return None
|
||||
return cfg if cfg.get("base_url") else None
|
||||
|
||||
|
||||
def _build_client(cfg: dict) -> DockhandClient:
|
||||
return DockhandClient(cfg["base_url"], token=cfg.get("token") or "",
|
||||
verify_ssl=cfg.get("verify_ssl", True))
|
||||
|
||||
|
||||
def test_connection(cfg: dict) -> dict:
|
||||
"""Token + URL probe: the environment list only, the exports are too slow
|
||||
for a synchronous request."""
|
||||
client = _build_client(cfg)
|
||||
try:
|
||||
return {"ok": True, "environments": len(client.environments())}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _entry(f: dict) -> dict:
|
||||
"""One Dockhand finding in container_scan_service's entry shape."""
|
||||
cve = str(f.get("cve") or "").strip()
|
||||
return {
|
||||
"ids": [cve.upper()] if cs.CVE_ID.match(cve) else [cve],
|
||||
"severity": cs.severity_band(f.get("severity"), None),
|
||||
"cvss": None,
|
||||
"fixed": bool(f.get("fixedVersion")),
|
||||
"package": f.get("package"),
|
||||
"version": f.get("installedVersion"),
|
||||
"fixed_version": f.get("fixedVersion") or None,
|
||||
"title": None,
|
||||
"description": f.get("description"),
|
||||
"url": f.get("link"),
|
||||
}
|
||||
|
||||
|
||||
def _env_label(env: dict, hostname: str = "") -> str:
|
||||
"""`prod (host hhdocker01, hawser-edge, 203.0.113.10)` — the name the
|
||||
operator gave the environment in Dockhand, the Docker host's own name, and
|
||||
how it is reached. Dockhand exposes no Docker daemon id (and Podman has
|
||||
none), so the hostname is what names the machine."""
|
||||
if hostname in ("", "unknown", env.get("name")):
|
||||
hostname = ""
|
||||
extra = [x for x in (hostname and f"host {hostname}", env.get("connectionType"),
|
||||
env.get("host"), env.get("publicIp")) if x]
|
||||
name = str(env.get("name") or env["id"])
|
||||
return f"{name} ({', '.join(map(str, extra))})" if extra else name
|
||||
|
||||
|
||||
def _short_id(value: str) -> str:
|
||||
"""The 12-character id `docker ps` / `docker images` show."""
|
||||
return str(value or "").removeprefix("sha256:")[:12]
|
||||
|
||||
|
||||
def _where(runs: Dict[str, dict]) -> str:
|
||||
"""Asset description: on which Docker host the image runs, and as which
|
||||
containers. One asset per image (see module docstring) — this is what tells
|
||||
the hosts apart. Container ids change with every recreate, so they are
|
||||
shown, never matched on; the image id tells a host still on the old build
|
||||
from one already updated."""
|
||||
lines = ["Container image, via Dockhand:"]
|
||||
for env, run in sorted(runs.items()):
|
||||
parts = ["image " + ", ".join(sorted(run["image_ids"])) if run["image_ids"] else ""]
|
||||
if run["containers"]:
|
||||
parts.append("containers " + ", ".join(
|
||||
f"{name} ({cid})" for cid, name in sorted(run["containers"].items(), key=lambda c: c[1])))
|
||||
if run["stacks"]:
|
||||
parts.append("stack " + ", ".join(sorted(run["stacks"])))
|
||||
lines.append(f"- {env}: " + "; ".join(p for p in parts if p))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _is_ip(value: str) -> bool:
|
||||
try:
|
||||
ipaddress.ip_address(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _host_asset(db: Session, hostname: str, ip: Optional[str]) -> Optional[Asset]:
|
||||
"""The host asset (Wazuh, Nessus, vCenter …) of the Docker host that calls
|
||||
itself `hostname`, or None. The exact name first, then FQDN vs short form
|
||||
(docker-01 ↔ docker-01.corp.example). Two candidates and the address does
|
||||
not decide → no link: an image on the wrong host is worse than none."""
|
||||
name = hostname.strip().lower()
|
||||
if name in ("", "unknown") or _is_ip(name):
|
||||
return None
|
||||
q = db.query(Asset).filter(Asset.source != AssetSource.CONTAINER,
|
||||
Asset.status != AssetStatus.DECOMMISSIONED)
|
||||
rows = q.filter(Asset.hostname.ilike(like_literal(name), escape="\\")).all()
|
||||
if not rows:
|
||||
short = like_literal(name.split(".")[0])
|
||||
rows = [a for a in q.filter(Asset.hostname.ilike(short, escape="\\")
|
||||
| Asset.hostname.ilike(short + ".%", escape="\\"))
|
||||
if not different_hosts(a.hostname, name)]
|
||||
return pick_one(sorted(rows, key=lambda a: a.id), ip, name)
|
||||
|
||||
|
||||
def _place(db: Session, placed: Dict[str, Dict[int, set]], ingested: List[str]) -> int:
|
||||
"""Rewrite container_placements from this sync. Only images that imported
|
||||
get a row; the whole table is Dockhand's, so what is not rewritten is gone
|
||||
from every host. Returns the number of links."""
|
||||
db.query(ContainerPlacement).delete()
|
||||
n = 0
|
||||
for image in ingested:
|
||||
asset_id = (db.query(Asset.id)
|
||||
.filter(Asset.source == AssetSource.CONTAINER, Asset.hostname == image)
|
||||
.scalar())
|
||||
for host_id, names in sorted(placed.get(image, {}).items()):
|
||||
db.add(ContainerPlacement(image_asset_id=asset_id, host_asset_id=host_id,
|
||||
containers=", ".join(sorted(names)) or None))
|
||||
n += 1
|
||||
db.commit()
|
||||
return n
|
||||
|
||||
|
||||
def run_dockhand_sync(db: Session, rescan: bool = False) -> dict:
|
||||
"""`rescan` (nightly job only) rescans each environment first when the
|
||||
config enables `scan_nightly` — too slow for Sync now."""
|
||||
cfg = load_dockhand_config(db)
|
||||
if not cfg:
|
||||
raise RuntimeError("Dockhand is not configured (settings.dockhand_config missing/incomplete).")
|
||||
from app.database import advisory_lock
|
||||
with advisory_lock(_SYNC_ADVISORY_LOCK_KEY) as got:
|
||||
if not got:
|
||||
logger.warning("Dockhand sync skipped — another Dockhand sync holds the lock")
|
||||
return {"skipped": "another sync already running"}
|
||||
return _run_dockhand_sync_locked(db, cfg, rescan=rescan)
|
||||
|
||||
|
||||
def _rescan(client: DockhandClient, env: dict, stats: dict) -> None:
|
||||
name = env.get("name") or env["id"]
|
||||
try:
|
||||
got = client.scan_all(env["id"])
|
||||
except DockhandError as e:
|
||||
stats["errors"].append(f"rescan {name}: {e}")
|
||||
return
|
||||
if "total" not in got:
|
||||
stats["errors"].append(f"rescan {name}: {got.get('error') or got}")
|
||||
return
|
||||
stats["images_rescanned"] += got.get("scanned") or 0
|
||||
if got.get("failed"):
|
||||
stats["errors"].append(f"rescan {name}: {got['failed']} of {got['total']} images failed")
|
||||
|
||||
|
||||
def _run_dockhand_sync_locked(db: Session, cfg: dict, rescan: bool = False) -> dict:
|
||||
min_severity = cfg.get("min_severity") or "high"
|
||||
only_fixed = cfg.get("only_fixed", True)
|
||||
stats = {"environments": 0, "images": 0, "untagged_skipped": 0, "no_id_skipped": 0,
|
||||
"baseline_images": 0,
|
||||
"filtered_severity": 0, "filtered_unfixed": 0, "kev_kept": 0, "vulns_created": 0,
|
||||
"vulns_merged": 0, "vulns_closed": 0, "images_rescanned": 0, "images_on_hosts": 0,
|
||||
"errors": []}
|
||||
rescan = rescan and bool(cfg.get("scan_nightly"))
|
||||
|
||||
# Fetch everything first: a failing environment raises here, before a
|
||||
# single row is written (see module docstring).
|
||||
by_image: Dict[str, List[dict]] = {}
|
||||
runs: Dict[str, Dict[str, dict]] = {} # image → env label → where it runs
|
||||
placed: Dict[str, Dict[int, set]] = {} # image → host asset id → container names
|
||||
client = _build_client(cfg)
|
||||
try:
|
||||
envs = client.environments()
|
||||
stats["environments"] = len(envs)
|
||||
for env in envs:
|
||||
if rescan:
|
||||
_rescan(client, env, stats)
|
||||
findings = client.findings(env["id"])
|
||||
# Dockhand drops images no longer on the host from the export —
|
||||
# but only while it can reach the host. Unreachable, it serves
|
||||
# every scan it ever stored, and /api/images answers []. Importing
|
||||
# that would revive deleted images and keep their findings open.
|
||||
if findings and not client.images(env["id"]):
|
||||
raise DockhandError(
|
||||
f"environment {env.get('name') or env['id']}: Dockhand lists no images but "
|
||||
f"{len(findings)} findings — Docker host unreachable? Sync aborted.")
|
||||
try:
|
||||
hostname = str(client.host(env["id"]).get("hostname") or "")
|
||||
except DockhandError as e:
|
||||
# Display only — the findings still import without it.
|
||||
logger.warning("Dockhand sync: host info for %s unavailable: %s", env.get("name"), e)
|
||||
hostname = ""
|
||||
label = _env_label(env, hostname)
|
||||
host = _host_asset(db, hostname, env.get("publicIp") or env.get("host"))
|
||||
for f in findings:
|
||||
name = str(f.get("imageName") or "")
|
||||
if not name or _BARE_DIGEST.match(name):
|
||||
stats["untagged_skipped"] += 1
|
||||
continue
|
||||
if not str(f.get("cve") or "").strip():
|
||||
stats["no_id_skipped"] += 1
|
||||
continue
|
||||
image = cs.normalise_image(name)
|
||||
by_image.setdefault(image, []).append(_entry(f))
|
||||
run = runs.setdefault(image, {}).setdefault(
|
||||
label, {"image_ids": set(), "containers": {}, "stacks": set()})
|
||||
if f.get("imageId"):
|
||||
run["image_ids"].add(_short_id(f["imageId"]))
|
||||
for c in f.get("containers") or []:
|
||||
cid = _short_id(c.get("id"))
|
||||
run["containers"][cid] = str(c.get("name") or "").lstrip("/") or cid
|
||||
if host is not None:
|
||||
placed.setdefault(image, {}).setdefault(host.id, set()).update(
|
||||
run["containers"].values())
|
||||
run["stacks"].update(s for s in f.get("stacks") or [] if s)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
ingested: List[str] = []
|
||||
for image, entries in sorted(by_image.items()):
|
||||
try:
|
||||
got = cs.ingest_image(db, SOURCE_NAME, image, entries,
|
||||
description=_where(runs[image]),
|
||||
min_severity=min_severity, only_fixed=only_fixed)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("Dockhand sync: %s failed: %s", image, e)
|
||||
stats["errors"].append(f"{image}: {e}")
|
||||
continue
|
||||
stats["images"] += 1
|
||||
ingested.append(image)
|
||||
stats["baseline_images"] += int(got["baseline"])
|
||||
for k in ("filtered_severity", "filtered_unfixed", "kev_kept", "vulns_created",
|
||||
"vulns_merged", "vulns_closed"):
|
||||
stats[k] += got[k]
|
||||
|
||||
stats["images_on_hosts"] = _place(db, placed, ingested)
|
||||
logger.info("Dockhand sync: %s", {k: v for k, v in stats.items() if k != "errors"})
|
||||
return stats
|
||||
@@ -968,7 +968,10 @@ def send_nightly_new_vuln_digest(db: Session) -> dict:
|
||||
vulns = (
|
||||
db.query(Vulnerability)
|
||||
.filter(Vulnerability.status.in_(active),
|
||||
Vulnerability.detected_at >= since)
|
||||
Vulnerability.detected_at >= since,
|
||||
# A container image's first report is a silent baseline,
|
||||
# see container_scan_service.
|
||||
Vulnerability.baseline == False) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
stats = dispatch_new_vuln_notifications(db, vulns, respect_schedule=False)
|
||||
|
||||
@@ -378,6 +378,8 @@ def build_index(db: Session) -> Dict[str, list]:
|
||||
"component": ((v0.get("package") or {}).get("name") or "").strip(),
|
||||
"cvss": _cvss_of(adv),
|
||||
"sev": _SEV.get((adv.get("severity") or "").lower()),
|
||||
# The maintainer's word as published, for the badge.
|
||||
"vendor_sev": (adv.get("severity") or "").strip() or None,
|
||||
"range": v0.get("vulnerable_version_range"),
|
||||
"patched": v0.get("patched_versions"),
|
||||
"summary": (adv.get("summary") or "")[:400],
|
||||
@@ -506,6 +508,7 @@ def scan_asset(db: Session, asset, packages: list, new_ids: Optional[list] = Non
|
||||
# A fixed_version is written once and never revised, so a wrong one
|
||||
# outlives the finding; empty is recoverable.
|
||||
c = {"cve": e["cve"], "cvss": e.get("cvss"), "severity": e.get("sev"),
|
||||
"vendor_sev": (f"GitHub ({repo})", e.get("vendor_sev")),
|
||||
"fixed": (str(e.get("patched") or "").strip().lstrip("vV")
|
||||
if _vt(e.get("patched")) else None),
|
||||
# The advisory's own summary. It was cached but never passed
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
IGEL Security Notices (ISN) → findings for IGEL OS endpoints and the UMS server.
|
||||
|
||||
The vendor source for IGEL, for the same reason TeamViewer has its bulletins
|
||||
(teamviewer_bulletin_service) and Firefox has MFSA: the CVE feeds do not see
|
||||
everything the vendor states.
|
||||
|
||||
1. NOT EVERY ISN HAS A CVE. ISN 2026-09 (UMS information disclosure, 8.6),
|
||||
ISN 2025-31 (stored XSS in UMS, 8.0) and ISN 2025-24 (command execution
|
||||
in IGEL OS, 8.8) carry no CVE id at all — a CVE-keyed source cannot see
|
||||
them however well it parses. The ISN id stands in as the identifier,
|
||||
exactly as TV-, GHSA- and NESSUS-PLUGIN- ids already do in this schema.
|
||||
2. THE FIX IS STATED PER RELEASE LINE, in the "Update Instructions" section:
|
||||
"OS 12: Update to IGEL OS 12.7.1. OS 11: Update to IGEL OS 11.10.410".
|
||||
That is exactly the per-line bound scan_asset_igel needs, and for the
|
||||
ISNs without a CVE it is the only place it is written down.
|
||||
|
||||
The page is prose, not a table. What is read:
|
||||
|
||||
* the header — "First published … CVSS:3.1: 8.6 (High) CVSS:3.1/AV:N/…" —
|
||||
for the score and severity (both optional: 66 of 198 ISNs state one of
|
||||
them loosely or not at all);
|
||||
* every CVE id anywhere on the page;
|
||||
* the "Update Instructions" section, split at its release-line anchors
|
||||
("OS 12:", "OS 11:", "UMS 12:", "UMS:") — the first version in each chunk
|
||||
is that line's fix.
|
||||
|
||||
A chunk whose version is not on the line's own major is NOT a firmware bound
|
||||
and is dropped: "OS 12: Update the Chromium app to 148.0.7778.96" is an app
|
||||
version, and the base-system version on the asset says nothing about which
|
||||
Chromium app is installed. The one exception is a UMS line told to upgrade to
|
||||
a newer major ("UMS 6: Upgrade to UMS 12.02.130"), which affects the whole old
|
||||
line. Same rule as TeamViewer's "affected, no bound": a chunk without a
|
||||
version ("IGEL is preparing a fixed OS 12 base system") yields nothing, since
|
||||
"affected, no fix yet" cannot become a per-device verdict without flagging
|
||||
every device forever once the vendor forgets to update the page.
|
||||
|
||||
Two products are in scope and both come from the asset's OS string, which
|
||||
the UMS connector writes: "IGEL OS" for endpoints (release from the version,
|
||||
never from the name — see cvelistv5_scan_service.IGEL_OS_RE) and "IGEL
|
||||
Universal Management Suite" for the server. ISNs for ICG, OS Creator, W10 and
|
||||
the Windows Embedded line have no anchor and are skipped.
|
||||
|
||||
Findings are written through the app-scan upsert with the app-scan source.
|
||||
The OS path relies on scan_asset_igel's reconcile (it runs after this one and
|
||||
folds the shared `touched` set in); the UMS server had no reconcile at all, so
|
||||
this pass closes its own stale findings.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INDEX_SETTING = "igel_isn_cache_v1"
|
||||
INDEX_TS_SETTING = "igel_isn_cache_v1_ts"
|
||||
TTL_HOURS = 24
|
||||
BASE = "https://kb.igel.com/en/security-safety/current"
|
||||
_SLUG_RE = re.compile(r'href="/en/security-safety/current/(isn-\d{4}-\d+[^"#?]*)"', re.I)
|
||||
|
||||
# "OS 12:", "IGEL OS 11:", "UMS 12:", "UMS:", "UMS 6.x:". The colon is required:
|
||||
# without it "IGEL OS 11.10.410" inside a chunk reads as a new anchor "OS 11"
|
||||
# and the fix degrades to "10.410".
|
||||
_ANCHOR_RE = re.compile(
|
||||
r"(?:IGEL\s+)?\b(OS|UMS|Universal Management Suite)\b\s*(\d+)?(?:\.x)?\s*:", re.I)
|
||||
_VER_RE = re.compile(r"(?<![\d.])(\d+\.\d+(?:\.\d+)*)")
|
||||
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}", re.I)
|
||||
_SEV_RE = re.compile(r"\b(critical|high|medium|moderate|low)\b", re.I)
|
||||
_SEV = {"critical": "critical", "high": "high", "medium": "medium",
|
||||
"moderate": "medium", "low": "low"}
|
||||
|
||||
UMS_LABEL = "IGEL Universal Management Suite"
|
||||
|
||||
|
||||
def _plain_text(fragment: str) -> str:
|
||||
import html as _html
|
||||
return re.sub(r"\s+", " ",
|
||||
_html.unescape(re.sub(r"(?is)<[^>]+>", " ", fragment))).strip()
|
||||
|
||||
|
||||
def _sections(body: str) -> Dict[str, str]:
|
||||
"""heading → text, split at h2-h4."""
|
||||
parts = re.split(r"(?is)<h[2-4][^>]*>(.*?)</h[2-4]>", body)
|
||||
out: Dict[str, str] = {}
|
||||
for i in range(1, len(parts), 2):
|
||||
# The Confluence export appends its own <script> after the article.
|
||||
out[_plain_text(parts[i])] = re.sub(r"// Mark block images.*", "",
|
||||
_plain_text(parts[i + 1]))
|
||||
return out
|
||||
|
||||
|
||||
def _major(version: str) -> int:
|
||||
return int(version.split(".", 1)[0])
|
||||
|
||||
|
||||
def _parse_bounds(instructions: str) -> List[dict]:
|
||||
"""Update Instructions text → [{prod, release, fix, all}]."""
|
||||
bounds: List[dict] = []
|
||||
anchors = list(_ANCHOR_RE.finditer(instructions))
|
||||
if not anchors:
|
||||
# "Update to UMS 12.11.100 or newer when available." — one line, no
|
||||
# prefix. The product is the word, the release is the fix's major.
|
||||
if re.search(r"\b(UMS|IGEL OS)\b", instructions):
|
||||
m = _VER_RE.search(instructions)
|
||||
if m:
|
||||
prod = "ums" if re.search(r"\bUMS\b", instructions) else "os"
|
||||
bounds.append({"prod": prod, "release": _major(m.group(1)),
|
||||
"fix": m.group(1), "all": False})
|
||||
return bounds
|
||||
for i, a in enumerate(anchors):
|
||||
end = anchors[i + 1].start() if i + 1 < len(anchors) else len(instructions)
|
||||
chunk = instructions[a.end():end]
|
||||
m = _VER_RE.search(chunk)
|
||||
if not m:
|
||||
continue
|
||||
fix = m.group(1)
|
||||
prod = "os" if a.group(1).upper() == "OS" else "ums"
|
||||
release = int(a.group(2)) if a.group(2) else _major(fix)
|
||||
if _major(fix) == release:
|
||||
bounds.append({"prod": prod, "release": release, "fix": fix, "all": False})
|
||||
elif prod == "ums" and _major(fix) > release:
|
||||
bounds.append({"prod": prod, "release": release, "fix": fix, "all": True})
|
||||
# else: an app version on an OS line — not a firmware bound.
|
||||
return bounds
|
||||
|
||||
|
||||
def _parse_isn(slug: str, html_text: str) -> Optional[dict]:
|
||||
m = re.search(r"(?is)<(main|article)[^>]*>(.*?)</\1>", html_text)
|
||||
body = m.group(2) if m else html_text
|
||||
secs = _sections(body)
|
||||
text = _plain_text(body)
|
||||
|
||||
t = re.search(r"(?is)<h1[^>]*>(.*?)</h1>", body)
|
||||
title = _plain_text(t.group(1)) if t else slug
|
||||
# From the slug, not the heading: ISN 2023-18's h1 is written without
|
||||
# the "ISN 2023-18:" prefix every other notice carries.
|
||||
m = re.match(r"isn-(\d{4})-(\d+)", slug, re.I)
|
||||
if not m:
|
||||
return None
|
||||
isn_id = f"ISN-{m.group(1)}-{m.group(2)}"
|
||||
|
||||
# Header = after the LAST "First published" (an "Updated … (fix version
|
||||
# to 12.8.1)" line may precede it) and before "Summary".
|
||||
head = text.split("Summary", 1)[0]
|
||||
if "First published" in head:
|
||||
head = head.rsplit("First published", 1)[1]
|
||||
head = re.sub(r"\b3\.[01]\b", "", head) # the "CVSS:3.1" version token
|
||||
cvss = None
|
||||
for sm in re.finditer(r"(?<![\d.])(\d{1,2}\.\d)(?![\d.])", head):
|
||||
if float(sm.group(1)) <= 10:
|
||||
cvss = float(sm.group(1))
|
||||
break
|
||||
sm = _SEV_RE.search(head)
|
||||
sev = _SEV.get(sm.group(1).lower()) if sm else None
|
||||
|
||||
instr = next((v for k, v in secs.items()
|
||||
if re.search(r"update|mitigation|resolution", k, re.I)), "")
|
||||
affected = _parse_bounds(instr)
|
||||
if not affected:
|
||||
return None
|
||||
|
||||
cves = list(dict.fromkeys(c.upper() for c in _CVE_RE.findall(text)))
|
||||
desc = " ".join(x for x in (secs.get("Summary"), secs.get("Details")) if x)
|
||||
return {"id": isn_id, "url": f"{BASE}/{slug}", "title": title,
|
||||
"cves": cves, "cvss": cvss, "sev": sev,
|
||||
"desc": desc[:2000] or None, "affected": affected}
|
||||
|
||||
|
||||
def build_index(db: Session) -> Dict[str, dict]:
|
||||
"""Fetch the ISN overview, then every notice it links (~200 pages)."""
|
||||
import httpx
|
||||
|
||||
index: Dict[str, dict] = {}
|
||||
with httpx.Client(timeout=httpx.Timeout(30.0, connect=10.0),
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0 (+security scanner)"}) as client:
|
||||
try:
|
||||
r = client.get(BASE)
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.warning("igel-isn: overview fetch failed: %s", e)
|
||||
return _stored(db) or {}
|
||||
slugs = sorted({s.lower() for s in _SLUG_RE.findall(r.text)})
|
||||
if not slugs:
|
||||
logger.warning("igel-isn: overview linked no notices — keeping the last index")
|
||||
return _stored(db) or {}
|
||||
failed = 0
|
||||
for slug in slugs:
|
||||
try:
|
||||
p = client.get(f"{BASE}/{slug}")
|
||||
if p.status_code != 200:
|
||||
failed += 1
|
||||
continue
|
||||
entry = _parse_isn(slug, p.text)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.debug("igel-isn: %s failed: %s", slug, e)
|
||||
continue
|
||||
if entry:
|
||||
index[slug] = entry
|
||||
if failed and not index:
|
||||
return _stored(db) or {}
|
||||
no_cve = sum(1 for e in index.values() if not e["cves"])
|
||||
logger.info("igel-isn: %d notices, %d with a firmware/UMS bound "
|
||||
"(%d without a CVE id), %d unreachable",
|
||||
len(slugs), len(index), no_cve, failed)
|
||||
if not index:
|
||||
# Every page answered and none parsed — a markup change. Storing {}
|
||||
# would leave every ISN finding untouched for a scan, which the
|
||||
# reconcile reads as "no longer detected" → patched.
|
||||
logger.warning("igel-isn: %d pages parsed, no bounds — keeping the last index",
|
||||
len(slugs))
|
||||
return _stored(db) or {}
|
||||
_store(db, index)
|
||||
return index
|
||||
|
||||
|
||||
def _store(db: Session, index: Dict[str, dict]) -> None:
|
||||
for key, val in ((INDEX_SETTING, json.dumps(index)),
|
||||
(INDEX_TS_SETTING, datetime.now().isoformat())):
|
||||
row = db.query(Setting).filter(Setting.key == key).first()
|
||||
if row:
|
||||
row.value = val
|
||||
else:
|
||||
db.add(Setting(key=key, value=val))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _stored(db: Session) -> Optional[Dict[str, dict]]:
|
||||
row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
|
||||
if not row or not row.value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row.value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def load_index(db: Session) -> Optional[Dict[str, dict]]:
|
||||
ts = db.query(Setting).filter(Setting.key == INDEX_TS_SETTING).first()
|
||||
if not ts or not ts.value:
|
||||
return None
|
||||
try:
|
||||
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=TTL_HOURS):
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
return _stored(db)
|
||||
|
||||
|
||||
def get_index(db: Session) -> Dict[str, dict]:
|
||||
idx = load_index(db)
|
||||
if idx is not None:
|
||||
return idx
|
||||
try:
|
||||
return build_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("igel-isn index build failed: %s", e)
|
||||
return _stored(db) or {}
|
||||
|
||||
|
||||
def _product(asset) -> Optional[str]:
|
||||
from app.services import cvelistv5_scan_service as c5
|
||||
name = (asset.operating_system or "").strip()
|
||||
if c5.IGEL_OS_RE.match(name):
|
||||
return "os"
|
||||
if name.lower().startswith(UMS_LABEL.lower()):
|
||||
return "ums"
|
||||
return None
|
||||
|
||||
|
||||
def _affected(installed: str, bound: dict) -> bool:
|
||||
from app.services import app_cve_scanner_service as cpe
|
||||
if bound["all"]:
|
||||
return True
|
||||
return (cpe._vcmp(installed, bound["fix"]) or 0) < 0
|
||||
|
||||
|
||||
def scan_asset(db: Session, asset, new_ids: Optional[list] = None,
|
||||
touched: Optional[set] = None, index: Optional[dict] = None) -> int:
|
||||
"""Match an IGEL OS endpoint or the UMS server against the ISNs.
|
||||
Returns findings upserted. Caller commits."""
|
||||
from app.services import app_cve_scanner_service as cpe
|
||||
from app.services import cvelistv5_scan_service as c5
|
||||
|
||||
prod = _product(asset)
|
||||
if not prod:
|
||||
return 0
|
||||
if index is None:
|
||||
index = get_index(db)
|
||||
if not index:
|
||||
return 0
|
||||
cver = cpe._clean_version(asset.os_version or "")
|
||||
if not cver:
|
||||
return 0
|
||||
release = c5.igel_release(cver)
|
||||
if release is None:
|
||||
return 0
|
||||
if new_ids is None:
|
||||
new_ids = []
|
||||
label = c5.IGEL_LABEL if prod == "os" else UMS_LABEL
|
||||
|
||||
count = 0
|
||||
still_affected: set = set()
|
||||
for entry in index.values():
|
||||
bound = next((b for b in entry["affected"]
|
||||
if b["prod"] == prod and b["release"] == release
|
||||
and _affected(cver, b)), None)
|
||||
if not bound:
|
||||
continue
|
||||
for ident in (entry["cves"] or [entry["id"]]):
|
||||
still_affected.add(ident.upper())
|
||||
c = {"cve": ident, "cvss": entry.get("cvss"),
|
||||
"severity": entry.get("sev"), "fixed": bound["fix"],
|
||||
"desc": entry.get("desc"),
|
||||
"refs": json.dumps([entry["url"]])}
|
||||
try:
|
||||
before = len(new_ids)
|
||||
cpe._upsert(db, asset, label, asset.os_version or cver, c,
|
||||
new_ids, touched=touched, vendor="IGEL")
|
||||
count += 1 if len(new_ids) > before else 0
|
||||
except Exception as e:
|
||||
logger.debug("igel-isn upsert failed (%s on %s): %s", ident, asset.id, e)
|
||||
|
||||
if prod == "ums":
|
||||
# The endpoint path reconciles in scan_asset_igel (which runs after
|
||||
# this and folds `touched` in). The UMS server has no other reconcile:
|
||||
# the CPE OS scan before this one never closed anything.
|
||||
c5._resolve_stale_appliance(db, asset, label,
|
||||
still_affected | set(touched or ()), cver,
|
||||
tag="igel-isn")
|
||||
return count
|
||||
@@ -31,6 +31,7 @@ from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus
|
||||
from app.services.asset_matching import match_by_hostname, match_by_ip, stable_serial
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -232,24 +233,39 @@ def _resolve_ip(host: str) -> Optional[str]:
|
||||
_SYNC_ADVISORY_LOCK_KEY = 0x54560103 # "TV" + 03
|
||||
|
||||
|
||||
# A network device Netdisco discovered is never a thin client or the UMS box: a
|
||||
# name or address shared with one is another device (field report 2026-09-15).
|
||||
_NOT_IGEL = (Asset.netdisco_device_ip,)
|
||||
|
||||
|
||||
def _find_or_create_asset(db: Session, *, unit_id: Optional[str], hostname: str,
|
||||
auto_create: bool, ip: Optional[str] = None,
|
||||
match_on_ip: bool = False):
|
||||
"""Match a UMS object to an asset by unit ID first, hostname second.
|
||||
match_on_ip: bool = False, serial: Optional[str] = None,
|
||||
live_unit_ids: frozenset = frozenset()):
|
||||
"""Match a UMS object to an asset: unit ID, serial, hostname.
|
||||
|
||||
Unit ID first because a thin client is renamed constantly — it is named
|
||||
after its desk, its user or its room, and all three change — while the unit
|
||||
ID is derived from the MAC and survives a factory reset and a re-
|
||||
registration. A rename must not fork the asset: the finding history hangs
|
||||
off it.
|
||||
ID is the MAC and survives a factory reset and a re-registration. A rename
|
||||
must not fork the asset: the finding history hangs off it.
|
||||
|
||||
The serial second, for the case the MAC does not survive: a board or NIC
|
||||
swap, a UD Pocket registered again. It only takes over an asset whose unit
|
||||
ID is absent from THIS run (`live_unit_ids`) — two UD Pockets booted on the
|
||||
same PC report the same host serial and are still two devices, and the
|
||||
serial must not pull one onto the other (same rule as the Wazuh agent id,
|
||||
0936cf4).
|
||||
"""
|
||||
unit_id = (unit_id or "").strip() or None
|
||||
serial = stable_serial(serial)
|
||||
hostname = (hostname or "").strip()
|
||||
short = hostname.split(".")[0] if hostname else ""
|
||||
|
||||
def _pin(a):
|
||||
if unit_id and a.igel_unit_id != unit_id:
|
||||
a.igel_unit_id = unit_id
|
||||
if serial and a.igel_serial != serial:
|
||||
a.igel_serial = serial
|
||||
|
||||
if unit_id:
|
||||
a = db.query(Asset).filter(Asset.igel_unit_id == unit_id).first()
|
||||
@@ -257,13 +273,21 @@ def _find_or_create_asset(db: Session, *, unit_id: Optional[str], hostname: str,
|
||||
_pin(a)
|
||||
return a, "unit"
|
||||
|
||||
if serial:
|
||||
for a in db.query(Asset).filter(Asset.igel_serial == serial).order_by(Asset.id):
|
||||
if not a.igel_unit_id or a.igel_unit_id not in live_unit_ids:
|
||||
_pin(a)
|
||||
return a, "serial"
|
||||
|
||||
for candidate in [c for c in (hostname, short) if c]:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
|
||||
a = match_by_hostname(db, candidate, pin_col=Asset.igel_unit_id, pin_value=unit_id,
|
||||
ip=ip, not_pinned_by=_NOT_IGEL)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "hostname"
|
||||
if short:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
|
||||
a = match_by_hostname(db, f"{short}.%", pin_col=Asset.igel_unit_id,
|
||||
pin_value=unit_id, ip=ip, not_pinned_by=_NOT_IGEL)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "hostname-fqdn-prefix"
|
||||
@@ -275,18 +299,20 @@ def _find_or_create_asset(db: Session, *, unit_id: Optional[str], hostname: str,
|
||||
# the network name already cover the case this was for. The UMS SERVER is
|
||||
# the one fixed address here, and it passes match_on_ip=True.
|
||||
if ip and match_on_ip:
|
||||
a = db.query(Asset).filter(Asset.ip_address == ip).first()
|
||||
a = match_by_ip(db, ip, hostname, not_pinned_by=_NOT_IGEL)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "ip"
|
||||
|
||||
if auto_create and hostname:
|
||||
a = Asset(hostname=short or hostname, ip_address=ip,
|
||||
igel_unit_id=unit_id, source=AssetSource.IGEL,
|
||||
igel_unit_id=unit_id, igel_serial=serial, source=AssetSource.IGEL,
|
||||
status=AssetStatus.ACTIVE)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
logger.info("IGEL sync: auto-created asset %s", a.hostname)
|
||||
from app.services.asset_lifecycle import created
|
||||
created(db, a, "IGEL")
|
||||
return a, "created"
|
||||
return None, "skipped"
|
||||
|
||||
@@ -308,7 +334,7 @@ def run_igel_sync(db: Session) -> dict:
|
||||
|
||||
|
||||
def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
from app.services.asset_lifecycle import reconcile_igel_by_seen_ids
|
||||
from app.services.asset_lifecycle import reconcile_igel_by_seen_ids, rename
|
||||
|
||||
auto_create = bool(cfg.get("auto_create_assets", True))
|
||||
sync_devices = bool(cfg.get("sync_devices", True))
|
||||
@@ -356,6 +382,7 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
stats["errors"].append(f"ums server: {e}")
|
||||
|
||||
# --- endpoint devices ---
|
||||
live_unit_ids = frozenset(d["unit_id"] for d in devices if d.get("unit_id"))
|
||||
for d in devices:
|
||||
stats["devices"] += 1
|
||||
try:
|
||||
@@ -376,7 +403,8 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
hostname = d.get("network_name") or d.get("name") or ""
|
||||
asset, how = _find_or_create_asset(
|
||||
db, unit_id=d.get("unit_id"), hostname=hostname,
|
||||
auto_create=auto_create, ip=d.get("ip_address"))
|
||||
auto_create=auto_create, ip=d.get("ip_address"),
|
||||
serial=d.get("serial"), live_unit_ids=live_unit_ids)
|
||||
if not asset:
|
||||
continue
|
||||
if how == "created":
|
||||
@@ -384,9 +412,9 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
else:
|
||||
stats["assets_matched"] += 1
|
||||
# Renames are the norm on a thin-client estate; matched by unit
|
||||
# ID means the name UMS reports now is the current one.
|
||||
if how == "unit" and hostname and asset.hostname != hostname:
|
||||
asset.hostname = hostname.split(".")[0] or hostname
|
||||
# ID or serial means the name UMS reports now is the current one.
|
||||
if how in ("unit", "serial") and hostname and asset.hostname != hostname:
|
||||
rename(db, asset, hostname.split(".")[0] or hostname, "IGEL")
|
||||
if d.get("ip_address"):
|
||||
asset.ip_address = d["ip_address"][:45]
|
||||
if resolved:
|
||||
@@ -499,7 +527,9 @@ def _run_cve_scan(db: Session, asset_ids: set) -> int:
|
||||
return 0
|
||||
from app.services import app_cve_scanner_service as cpe
|
||||
from app.services import cvelistv5_scan_service as c5
|
||||
from app.services import igel_isn_service as isn
|
||||
index = c5.load_index(db) or {}
|
||||
isn_index = isn.get_index(db)
|
||||
if not index:
|
||||
logger.info("IGEL sync: no cvelistV5 index yet — CVE pass deferred "
|
||||
"to the nightly app-CVE scan")
|
||||
@@ -511,6 +541,14 @@ def _run_cve_scan(db: Session, asset_ids: set) -> int:
|
||||
total += cpe.scan_asset_os(db, asset, new_ids, touched=touched)
|
||||
except Exception as e:
|
||||
logger.warning("IGEL CPE scan failed for %s: %s", asset.hostname, e)
|
||||
# The vendor's own notices — the only source for the ISNs without a
|
||||
# CVE id. Between the CPE pass and the cvelistV5 one on purpose: it
|
||||
# folds the CPE hits into the UMS reconcile, and scan_asset_igel
|
||||
# folds its hits into the endpoint one.
|
||||
try:
|
||||
total += isn.scan_asset(db, asset, new_ids, touched=touched, index=isn_index)
|
||||
except Exception as e:
|
||||
logger.warning("IGEL ISN scan failed for %s: %s", asset.hostname, e)
|
||||
if not index:
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus
|
||||
from app.services.asset_matching import match_by_hostname, stable_serial
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -138,6 +139,7 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
|
||||
device_id = (device.get("id") or "").strip() or None
|
||||
aad_id = _aad_id(device)
|
||||
hostname = _clean_device_name(device)
|
||||
serial = stable_serial(device.get("serialNumber"))
|
||||
|
||||
def _pin(a):
|
||||
# Adopt the ids Intune just gave us, so future syncs (and the Defender
|
||||
@@ -159,6 +161,12 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
|
||||
a.intune_device_id = device_id
|
||||
if aad_id and a.aad_device_id != aad_id:
|
||||
a.aad_device_id = aad_id
|
||||
# Written once and then left alone, unlike the two ids above: those
|
||||
# are enrolment facts and change with the enrolment, the serial is the
|
||||
# hardware and does not. Overwriting it would hand the identity to
|
||||
# whichever record the run happened to process last.
|
||||
if serial and not a.intune_serial:
|
||||
a.intune_serial = serial
|
||||
|
||||
if device_id:
|
||||
a = db.query(Asset).filter(Asset.intune_device_id == device_id).first()
|
||||
@@ -172,14 +180,36 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
|
||||
_pin(a)
|
||||
return a, "aad_id"
|
||||
|
||||
# Before the name: the hardware. A wipe-and-re-enrol issues a new device
|
||||
# id, so the two id stages above miss and the name is all that is left —
|
||||
# and a name is not an identifier (DESKTOP-XXXXXXX, a batch of userless
|
||||
# Androids). The serial tells the two apart.
|
||||
if serial:
|
||||
# limit(2): a serial two assets carry identifies neither. The pin is
|
||||
# written once, from this same check, so it should not happen — but a
|
||||
# placeholder that slipped the filter would look exactly like this,
|
||||
# and then the name still decides.
|
||||
rows = db.query(Asset).filter(Asset.intune_serial == serial).limit(2).all()
|
||||
if len(rows) == 1:
|
||||
_pin(rows[0])
|
||||
return rows[0], "intune-serial"
|
||||
if rows:
|
||||
logger.info("Intune sync: serial %s is carried by more than one "
|
||||
"asset — not an identifier", serial)
|
||||
|
||||
short = hostname.split(".")[0] if hostname else ""
|
||||
# Pinned on the SERIAL, not on intune_device_id: the device id changes on
|
||||
# re-enrolment and guarding on it would fork the asset of every re-enrolled
|
||||
# device. An asset carrying a different serial belongs to other hardware.
|
||||
for candidate in [c for c in (hostname, short) if c]:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
|
||||
a = match_by_hostname(db, candidate, pin_col=Asset.intune_serial,
|
||||
pin_value=serial)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "hostname"
|
||||
if short:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
|
||||
a = match_by_hostname(db, f"{short}.%", pin_col=Asset.intune_serial,
|
||||
pin_value=serial)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "hostname-fqdn-prefix"
|
||||
@@ -188,6 +218,7 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
|
||||
a = Asset(
|
||||
hostname=short or hostname,
|
||||
intune_device_id=device_id,
|
||||
intune_serial=serial,
|
||||
aad_device_id=aad_id,
|
||||
source=AssetSource.INTUNE,
|
||||
status=AssetStatus.ACTIVE,
|
||||
@@ -195,6 +226,8 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
|
||||
db.add(a)
|
||||
db.flush()
|
||||
logger.info("Intune sync: auto-created asset %s", a.hostname)
|
||||
from app.services.asset_lifecycle import created
|
||||
created(db, a, "Intune")
|
||||
return a, "created"
|
||||
|
||||
return None, "skipped"
|
||||
@@ -240,10 +273,16 @@ def _dedupe_reenrolled(devices: list) -> tuple:
|
||||
|
||||
Collapsing them here, before anything touches an asset, is the whole fix:
|
||||
the asset is pinned to the live enrolment and its app list REPLACES the
|
||||
old one instead of competing with it. Keyed on the cleaned short hostname
|
||||
the asset match ends up on anyway, so nothing collapses here that would
|
||||
not have collapsed onto one asset regardless. Devices with no usable name
|
||||
are never merged.
|
||||
old one instead of competing with it.
|
||||
|
||||
The key is the name AND the hardware serial, not the name alone. Two
|
||||
DIFFERENT devices can share a name — a Windows default DESKTOP-XXXXXXX, a
|
||||
batch of userless Androids — and collapsing those dropped one of them out
|
||||
of the sync entirely: no asset, no inventory, no findings, and nothing in
|
||||
the UI that says a system is missing. A re-enrolment keeps its serial (same
|
||||
hardware), so the case this function exists for still collapses; devices
|
||||
that report no serial fall back to the name and behave as before. Devices
|
||||
with no usable name are never merged.
|
||||
"""
|
||||
newest: dict = {}
|
||||
unnamed: list = []
|
||||
@@ -253,6 +292,7 @@ def _dedupe_reenrolled(devices: list) -> tuple:
|
||||
if not key:
|
||||
unnamed.append(d)
|
||||
continue
|
||||
key = (key, stable_serial(d.get("serialNumber")) or "")
|
||||
prev = newest.get(key)
|
||||
if prev is None:
|
||||
newest[key] = d
|
||||
@@ -261,10 +301,12 @@ def _dedupe_reenrolled(devices: list) -> tuple:
|
||||
else (d, prev))
|
||||
newest[key] = live
|
||||
dropped += 1
|
||||
logger.info("Intune sync: %s enrolled more than once — using device id "
|
||||
"%s (check-in %s), ignoring stale %s (check-in %s)",
|
||||
key, live.get("id"), live.get("lastSyncDateTime"),
|
||||
stale.get("id"), stale.get("lastSyncDateTime"))
|
||||
logger.info("Intune sync: %s (serial %s) enrolled more than once — "
|
||||
"using device id %s (check-in %s), ignoring stale %s "
|
||||
"(check-in %s)",
|
||||
key[0], key[1] or "-", live.get("id"),
|
||||
live.get("lastSyncDateTime"), stale.get("id"),
|
||||
stale.get("lastSyncDateTime"))
|
||||
return unnamed + list(newest.values()), dropped
|
||||
|
||||
|
||||
@@ -295,7 +337,7 @@ def run_intune_sync(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
|
||||
def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
from app.services import eol_service
|
||||
from app.services.asset_lifecycle import reconcile_intune_by_seen_ids
|
||||
from app.services.asset_lifecycle import reconcile_intune_by_seen_ids, rename
|
||||
|
||||
auto_create = bool(cfg.get("auto_create_assets", True))
|
||||
detected_apps_enabled = bool(cfg.get("detected_apps", True))
|
||||
@@ -328,8 +370,12 @@ def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
# Rename tracking: matched by a stable id → adopt the current
|
||||
# (cleaned) device name so autodeploy renames propagate.
|
||||
new_name = _clean_device_name(device)
|
||||
if how in ("intune_id", "aad_id") and new_name and asset.hostname != new_name:
|
||||
asset.hostname = new_name.split(".")[0] or new_name
|
||||
# "intune-serial" belongs here too: it means the id stages
|
||||
# missed (the device was re-enrolled) and we found the same
|
||||
# HARDWARE — so the name Intune reports now is the current one.
|
||||
if how in ("intune_id", "aad_id", "intune-serial") \
|
||||
and new_name and asset.hostname != new_name:
|
||||
rename(db, asset, new_name.split(".")[0] or new_name, "Intune")
|
||||
# refresh inventory fields
|
||||
os_name = _os_string(device)
|
||||
if os_name:
|
||||
@@ -395,7 +441,7 @@ def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
or asset.defender_machine_id)
|
||||
stats["app_findings"] += _run_app_inventory(
|
||||
db, asset, pkgs, reconcile=sole_source,
|
||||
seen_eol_ids=seen_eol_ids)
|
||||
inventory=SOURCE_NAME, seen_eol_ids=seen_eol_ids)
|
||||
except Exception as e:
|
||||
logger.debug("Intune detectedApps failed for %s: %s", asset.hostname, e)
|
||||
except Exception as e:
|
||||
@@ -438,10 +484,14 @@ def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
|
||||
def _run_app_inventory(db: Session, asset, packages: list, *,
|
||||
reconcile: bool = False,
|
||||
inventory: Optional[str] = None,
|
||||
seen_eol_ids: Optional[set] = None) -> int:
|
||||
"""Feed an inventory (Intune detectedApps, Defender software) into the
|
||||
existing EOL + M365 detection. Returns findings upserted (best-effort).
|
||||
|
||||
`inventory` names whose list this is, so a finding these scanners reopen
|
||||
cannot undo a close that is newer than the list (audit_events).
|
||||
|
||||
`reconcile` retracts the EOL findings this list no longer confirms —
|
||||
i.e. it REPLACES the asset's software inventory rather than adding to it.
|
||||
Only the caller knows whether it holds the whole inventory, so it is off by
|
||||
@@ -449,22 +499,25 @@ def _run_app_inventory(db: Session, asset, packages: list, *,
|
||||
its own partial list) "not in my list" is not "uninstalled", and retracting
|
||||
would flap against the other source.
|
||||
"""
|
||||
from app.services.audit_events import scanning_inventory
|
||||
|
||||
count = 0
|
||||
try:
|
||||
from app.services import eol_service
|
||||
count += eol_service.run_eol_for_packages(
|
||||
db, asset, packages,
|
||||
reconcile=reconcile, seen_ids=seen_eol_ids)["findings"]
|
||||
except Exception as e:
|
||||
logger.debug("Intune EOL-for-packages failed on %s: %s", asset.hostname, e)
|
||||
try:
|
||||
from app.services import m365_service
|
||||
count += m365_service.run_m365_for_packages(db, asset, packages)
|
||||
except Exception as e:
|
||||
logger.debug("Intune M365-for-packages failed on %s: %s", asset.hostname, e)
|
||||
try:
|
||||
from app.services import app_cve_scanner_service
|
||||
count += app_cve_scanner_service.scan_asset_packages(db, asset, packages)
|
||||
except Exception as e:
|
||||
logger.debug("Intune app-cve scan failed on %s: %s", asset.hostname, e)
|
||||
with scanning_inventory(inventory):
|
||||
try:
|
||||
from app.services import eol_service
|
||||
count += eol_service.run_eol_for_packages(
|
||||
db, asset, packages,
|
||||
reconcile=reconcile, seen_ids=seen_eol_ids)["findings"]
|
||||
except Exception as e:
|
||||
logger.debug("Intune EOL-for-packages failed on %s: %s", asset.hostname, e)
|
||||
try:
|
||||
from app.services import m365_service
|
||||
count += m365_service.run_m365_for_packages(db, asset, packages)
|
||||
except Exception as e:
|
||||
logger.debug("Intune M365-for-packages failed on %s: %s", asset.hostname, e)
|
||||
try:
|
||||
from app.services import app_cve_scanner_service
|
||||
count += app_cve_scanner_service.scan_asset_packages(db, asset, packages)
|
||||
except Exception as e:
|
||||
logger.debug("Intune app-cve scan failed on %s: %s", asset.hostname, e)
|
||||
return count
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Jev config hint — one structured question per CVE text, to TypeSafe Jev
|
||||
directly or via OpenRouter Decisions.
|
||||
|
||||
Jev (TypeSafe) is a decision model: typed answer + probability, no prose. It
|
||||
is asked only what no feed answers — KEV / EUVD / EPSS / SSVC and the CVSS
|
||||
vector already cover "exploited?", "how exploitable?", "remote?":
|
||||
|
||||
Is this CVE only exploitable with an optional feature, module or
|
||||
non-default configuration enabled?
|
||||
|
||||
The answer (noul probability) is stored per CVE as a HINT the patch plan lists
|
||||
("check whether this applies to your setup"). It never closes a finding and
|
||||
never enters priority_score: a finding closes only when its scanners retract
|
||||
it (README.DEV.md, "Cross-source contract").
|
||||
|
||||
Off by default: JEV_TRIAGE_ENABLED env or setting `jev_triage_enabled`, plus
|
||||
a key — a TypeSafe key (TYPESAFE_API_KEY / `typesafe_api_key`) goes straight
|
||||
to api.typesafe.ai, else the OpenRouter key the AI remediation already uses.
|
||||
Both endpoints take the same body and answer the same `answers`. Only public
|
||||
CVE text is sent — no hostnames, no asset data.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import distinct, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.audit_log import AuditLog, AuditEventType
|
||||
from app.models.vulnerability import Vulnerability
|
||||
from app.services.ai_service import _cfg
|
||||
from app.services.patch_plan_service import open_work
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (url, pinned model) per endpoint. Pinned, not the latest alias: a silent
|
||||
# model swap would shift every hint. The two ids differ, so the model goes
|
||||
# with the endpoint and is not configurable on its own.
|
||||
TYPESAFE_ENDPOINT = ("https://api.typesafe.ai/v1/systemone", "jev-1.13.0")
|
||||
OPENROUTER_ENDPOINT = ("https://openrouter.ai/api/alpha/decisions", "typesafe/jev-1.13")
|
||||
HTTP_TIMEOUT = 20.0
|
||||
MAX_CVES_PER_RUN = 500
|
||||
# This many CVE-specific failures in a row are the request, not the CVEs.
|
||||
MAX_CONSECUTIVE_SKIPS = 5
|
||||
|
||||
# "noul" is Jev's yes/no answer type: the answer is the probability of yes.
|
||||
QUESTION_KEY = "non_default_config"
|
||||
QUESTION = {
|
||||
"type": "noul",
|
||||
"instructions": (
|
||||
"Based on the description, is this vulnerability only exploitable when an "
|
||||
"optional feature, module, plugin or non-default configuration is enabled, "
|
||||
"rather than in a default installation of the product?"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def parse_noul(data: dict) -> float:
|
||||
try:
|
||||
p = data["answers"][QUESTION_KEY]["noul"]
|
||||
except (KeyError, TypeError):
|
||||
raise ValueError(f"no {QUESTION_KEY} answer in Jev response")
|
||||
if isinstance(p, bool) or not isinstance(p, (int, float)) or not 0.0 <= p <= 1.0:
|
||||
raise ValueError(f"Jev returned {p!r}, not a probability")
|
||||
return float(p)
|
||||
|
||||
|
||||
def usage_tokens(data: dict) -> int:
|
||||
"""Tokens this answer cost, 0 when the endpoint does not report any.
|
||||
|
||||
Neither endpoint promises a `usage` block, and a missing one must never
|
||||
cost an answer — so this only ever reads what is there.
|
||||
"""
|
||||
u = data.get("usage") if isinstance(data, dict) else None
|
||||
if not isinstance(u, dict):
|
||||
return 0
|
||||
total = u.get("total_tokens")
|
||||
if not isinstance(total, (int, float)):
|
||||
total = sum(u.get(k) or 0 for k in ("prompt_tokens", "completion_tokens")
|
||||
if isinstance(u.get(k), (int, float)))
|
||||
try:
|
||||
return max(0, int(total))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _cve_specific(e: Exception) -> bool:
|
||||
"""An unusable answer, or a 4xx that is about the request rather than the
|
||||
account (401/402/403 auth or credit, 429 rate limit)."""
|
||||
if isinstance(e, ValueError):
|
||||
return True
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
code = e.response.status_code
|
||||
return 400 <= code < 500 and code not in (401, 402, 403, 429)
|
||||
return False
|
||||
|
||||
|
||||
def ask_jev(url: str, api_key: str, model: str, state: dict) -> Tuple[float, int]:
|
||||
"""(probability, tokens the endpoint reported for this call)."""
|
||||
r = httpx.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={"model": model, "state": state, "questions": {QUESTION_KEY: QUESTION}},
|
||||
timeout=HTTP_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
tokens = usage_tokens(data)
|
||||
try:
|
||||
return parse_noul(data), tokens
|
||||
except ValueError as e:
|
||||
# The call went through and is billed; only the payload is unusable.
|
||||
# The caller skips this CVE but must still count what it cost.
|
||||
e.tokens = tokens
|
||||
raise
|
||||
|
||||
|
||||
def audit_run(db: Session, stats: dict, cves: list, model: str) -> None:
|
||||
"""One audit row per run: what it cost and what it covered.
|
||||
|
||||
Reuses AI_ANALYSIS_REQUESTED — a Jev run is an AI call, and an own event
|
||||
type would be an enum migration for one row a night. Never raises: this
|
||||
is bookkeeping, the answers are already committed.
|
||||
|
||||
A run that did nothing (nothing pending, or disabled) writes nothing —
|
||||
an audit log that fills with "0 CVEs" every night hides the real rows.
|
||||
"""
|
||||
if not (stats["checked"] or stats["error"] or stats["skipped_cves"]):
|
||||
return
|
||||
try:
|
||||
hosts = 0
|
||||
if cves:
|
||||
hosts = open_work(db.query(func.count(distinct(Vulnerability.asset_id)))) \
|
||||
.filter(Vulnerability.cve_id.in_(cves)).scalar() or 0
|
||||
stats["hosts"] = hosts
|
||||
tokens = stats.get("tokens") or 0
|
||||
if tokens:
|
||||
cost = f"{tokens:,} tokens"
|
||||
elif stats["checked"]:
|
||||
cost = "tokens not reported by the endpoint"
|
||||
else:
|
||||
# Nothing was answered — saying the endpoint reports no usage
|
||||
# would be a claim about a call that never came back.
|
||||
cost = "no tokens counted"
|
||||
desc = (f"Jev triage: {stats['checked']} CVEs checked on {hosts} hosts, "
|
||||
f"{cost} ({model})")
|
||||
if stats.get("error"):
|
||||
desc += f" — stopped: {stats['error']}"
|
||||
db.add(AuditLog(
|
||||
user_id=None, # System/Auto, like the sync events
|
||||
event_type=AuditEventType.AI_ANALYSIS_REQUESTED,
|
||||
event_description=desc[:500],
|
||||
resource_type="jev_triage",
|
||||
new_value=json.dumps({
|
||||
"checked": stats["checked"], "hosts": hosts, "tokens": tokens,
|
||||
"model": model, "cves": cves,
|
||||
"skipped_cves": stats.get("skipped_cves") or [],
|
||||
"error": stats.get("error"),
|
||||
}),
|
||||
timestamp=datetime.now(),
|
||||
))
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Jev triage audit entry failed: %s", e)
|
||||
db.rollback()
|
||||
|
||||
|
||||
def run_jev_triage(db: Session,
|
||||
ask: Optional[Callable[[str, str, str, dict], Tuple[float, int]]] = None) -> dict:
|
||||
ask = ask or ask_jev
|
||||
enabled = _cfg(db, "JEV_TRIAGE_ENABLED", "jev_triage_enabled").lower() in ("1", "true", "yes")
|
||||
# TypeSafe first when both are set: same answer, one hop less.
|
||||
api_key = _cfg(db, "TYPESAFE_API_KEY", "typesafe_api_key")
|
||||
url, model = TYPESAFE_ENDPOINT
|
||||
if not api_key:
|
||||
api_key = _cfg(db, "OPENROUTER_API_KEY", "openrouter_api_key")
|
||||
url, model = OPENROUTER_ENDPOINT
|
||||
if not (enabled and api_key):
|
||||
return {"skipped": "disabled or no TypeSafe / OpenRouter key", "checked": 0}
|
||||
|
||||
# One row per CVE is enough — the question is about the CVE text.
|
||||
# ponytail: answered once, never re-asked; clear jev_checked_at to redo.
|
||||
pending = (
|
||||
open_work(db.query(Vulnerability.cve_id, Vulnerability.title,
|
||||
Vulnerability.description, Vulnerability.package_name))
|
||||
.filter(Vulnerability.jev_checked_at.is_(None))
|
||||
.filter(Vulnerability.cve_id.like("CVE-%"))
|
||||
.filter(Vulnerability.description.isnot(None))
|
||||
.order_by(Vulnerability.priority_score.desc())
|
||||
.all()
|
||||
)
|
||||
seen, streak = set(), 0
|
||||
stats = {"checked": 0, "tokens": 0, "hosts": 0, "skipped_cves": [], "error": None}
|
||||
answered = []
|
||||
for cve, title, description, product in pending:
|
||||
if cve in seen:
|
||||
continue
|
||||
seen.add(cve)
|
||||
if len(seen) > MAX_CVES_PER_RUN:
|
||||
break
|
||||
try:
|
||||
answer = ask(url, api_key, model, {"cve": cve, "product": product, "title": title,
|
||||
"description": description})
|
||||
except Exception as e:
|
||||
# A rejected answer was still paid for (ask_jev attaches what it cost).
|
||||
stats["tokens"] += getattr(e, "tokens", 0)
|
||||
streak += 1
|
||||
if _cve_specific(e) and streak < MAX_CONSECUTIVE_SKIPS:
|
||||
# This CVE's text or answer, not the API — skip it, go on;
|
||||
# otherwise it would stop the run at the same CVE every night.
|
||||
logger.warning("Jev triage skipped %s: %s", cve, e)
|
||||
stats["skipped_cves"].append(cve)
|
||||
continue
|
||||
# Auth, quota, network, server, or the same rejection CVE after
|
||||
# CVE — systemic, so stop; everything unanswered stays unstamped
|
||||
# and the next run retries it.
|
||||
logger.error("Jev triage stopped at %s: %s", cve, e)
|
||||
stats["error"] = str(e)
|
||||
break
|
||||
streak = 0
|
||||
p, tokens = answer
|
||||
stats["tokens"] += tokens
|
||||
answered.append(cve)
|
||||
db.query(Vulnerability).filter(Vulnerability.cve_id == cve).update(
|
||||
{"jev_config_dependent": p, "jev_checked_at": datetime.utcnow()},
|
||||
synchronize_session=False,
|
||||
)
|
||||
stats["checked"] += 1
|
||||
if stats["checked"] % 50 == 0:
|
||||
db.commit()
|
||||
db.commit()
|
||||
audit_run(db, stats, answered, model)
|
||||
logger.info("Jev triage: %s", stats)
|
||||
return stats
|
||||
@@ -70,8 +70,11 @@ def euvd_is_exploited(entry: dict) -> bool:
|
||||
|
||||
|
||||
def _parse_date(s) -> Optional[datetime]:
|
||||
# fromisoformat, not strptime: this runs once per catalog entry per merge
|
||||
# AND once per entry per sort — tens of thousands of calls on one dashboard
|
||||
# load — and strptime costs ~15x more for the same "YYYY-MM-DD".
|
||||
try:
|
||||
return datetime.strptime(str(s)[:10], "%Y-%m-%d")
|
||||
return datetime.fromisoformat(str(s)[:10])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@@ -96,6 +99,76 @@ def _normalize_sources(sources: Optional[Sequence[str]]) -> tuple:
|
||||
return picked or ALL_SOURCES
|
||||
|
||||
|
||||
# Rebuilding the merged catalog means parsing ~2 MB of JSON out of `settings`
|
||||
# and merging every entry — 60 ms of pure Python, on a request the dashboard
|
||||
# makes four times per page load. The sources behind it refresh once a day, so
|
||||
# the rebuild is repeated work almost every time.
|
||||
#
|
||||
# Memoized on the source caches' own `_updated_at` values, not on a clock: a
|
||||
# refresh (nightly or by hand) writes a new timestamp and the memo is stale
|
||||
# the same instant, so "refresh now, look at the page" still shows the new
|
||||
# catalog. When those timestamps cannot be read at all — a test stub, an
|
||||
# install whose caches were never filled — the identity of the catalog is
|
||||
# unknown and nothing is memoized.
|
||||
# ponytail: per-process. Several workers each keep their own copy; that only
|
||||
# needs a shared cache if the copies ever have to agree on the same second.
|
||||
_catalog_memo: Dict[tuple, tuple] = {}
|
||||
|
||||
|
||||
def _ts_keys() -> Dict[str, str]:
|
||||
"""source id → settings key holding the time of its last successful fetch."""
|
||||
from app.services.enrichment_service import (
|
||||
EUVD_CACHE_TS_KEY, KEV_CACHE_TS_KEY, VULNCHECK_CACHE_TS_KEY)
|
||||
return {"cisa": KEV_CACHE_TS_KEY, "euvd": EUVD_CACHE_TS_KEY,
|
||||
"vulncheck": VULNCHECK_CACHE_TS_KEY}
|
||||
|
||||
|
||||
def source_status(db: Session) -> Dict[str, dict]:
|
||||
"""Per source: when its catalog was last fetched (ISO string or None) and
|
||||
whether it can be fetched at all — VulnCheck needs a token. The Advisories
|
||||
page shows this next to the KEV list; the RSS feeds keep their own stamp."""
|
||||
from app.models.setting import Setting
|
||||
from app.services.enrichment_service import vulncheck_token
|
||||
keys = _ts_keys()
|
||||
rows = dict(db.query(Setting.key, Setting.value)
|
||||
.filter(Setting.key.in_(list(keys.values()))).all())
|
||||
return {s: {"updated_at": rows.get(k),
|
||||
"configured": s != "vulncheck" or bool(vulncheck_token())}
|
||||
for s, k in keys.items()}
|
||||
|
||||
|
||||
def refresh_kev_sources(db: Session) -> Dict[str, dict]:
|
||||
"""Re-fetch every KEV catalog now, past the 24h cache. One dead catalog
|
||||
must not stop the others, so failures are recorded per source.
|
||||
|
||||
A fetch that fails but finds an old cache returns that cache without
|
||||
raising, so a source can report no error and still keep its old
|
||||
`updated_at` — the timestamp, not the error field, says what is current."""
|
||||
from app.services import enrichment_service as enr
|
||||
fetchers = {"cisa": enr.fetch_kev_catalog, "euvd": enr.fetch_euvd_catalogs,
|
||||
"vulncheck": enr.fetch_vulncheck_kev}
|
||||
out: Dict[str, dict] = {}
|
||||
for source, fetch in fetchers.items():
|
||||
try:
|
||||
out[source] = {"entries": len(fetch(db, force_refresh=True)), "error": None}
|
||||
except Exception as e:
|
||||
logger.warning("kev: %s refresh failed: %s", source, e)
|
||||
out[source] = {"entries": None, "error": f"{type(e).__name__}: {e}"[:200]}
|
||||
return out
|
||||
|
||||
|
||||
def _catalog_stamp(db: Session, picked: tuple) -> Optional[str]:
|
||||
"""Identity of the underlying source caches, or None when unreadable."""
|
||||
from app.models.setting import Setting
|
||||
ts_keys = _ts_keys()
|
||||
try:
|
||||
rows = (db.query(Setting.key, Setting.value)
|
||||
.filter(Setting.key.in_([ts_keys[s] for s in picked])).all())
|
||||
except Exception:
|
||||
return None
|
||||
return "|".join(f"{k}={v}" for k, v in sorted(rows)) or None
|
||||
|
||||
|
||||
def get_kev_catalog(db: Session, sources: Optional[Sequence[str]] = None) -> Dict[str, dict]:
|
||||
"""{CVE-ID: entry} of everything known to be actively exploited.
|
||||
|
||||
@@ -108,6 +181,10 @@ def get_kev_catalog(db: Session, sources: Optional[Sequence[str]] = None) -> Dic
|
||||
yesterday is news yesterday. Per-source dates stay in `source_dates`.
|
||||
"""
|
||||
picked = _normalize_sources(sources)
|
||||
stamp = _catalog_stamp(db, picked)
|
||||
memo = _catalog_memo.get(picked)
|
||||
if stamp and memo and memo[0] == stamp:
|
||||
return memo[1]
|
||||
out: Dict[str, dict] = {}
|
||||
|
||||
def _touch(cve: str, source: str, date_added) -> dict:
|
||||
@@ -178,6 +255,8 @@ def get_kev_catalog(db: Session, sources: Optional[Sequence[str]] = None) -> Dic
|
||||
except Exception as ex:
|
||||
logger.warning("kev: VulnCheck KEV catalog unavailable: %s", ex)
|
||||
|
||||
if stamp:
|
||||
_catalog_memo[picked] = (stamp, out)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -108,6 +108,34 @@ def is_m365_apps(product_name: str) -> bool:
|
||||
return "microsoft 365 apps" in n or "office 365 proplus" in n
|
||||
|
||||
|
||||
def _anchor_candidates(date_label: str) -> List[str]:
|
||||
"""Anchor forms for a date heading, most likely first.
|
||||
|
||||
Learn slugifies the heading verbatim, so the day is written exactly as the
|
||||
heading writes it — and Microsoft is inconsistent about zero padding
|
||||
("December 09, 2025" -> #december-09-2025, "June 9, 2026" -> #june-9-2026).
|
||||
Both forms are offered and the caller keeps the one the page really has.
|
||||
"""
|
||||
m = _DATE_RE.fullmatch((date_label or "").strip())
|
||||
if not m:
|
||||
return []
|
||||
month, day, year = m.group(1).lower(), m.group(2), m.group(3)
|
||||
forms = [day, str(int(day))]
|
||||
return [f"{month}-{d}-{year}" for d in dict.fromkeys(forms)]
|
||||
|
||||
|
||||
def security_page_url(anchor: Optional[str] = None) -> str:
|
||||
"""The Learn security-updates page, deep-linked to a monthly section.
|
||||
|
||||
`anchor` is a heading id read off the page itself (see
|
||||
_parse_security_page), so a finding points straight at the release that
|
||||
lists its CVE instead of the top of a very long page. Without one — an
|
||||
older cache, a heading Microsoft renamed — the plain page URL: a link to
|
||||
the right page beats one to an anchor that isn't there.
|
||||
"""
|
||||
return f"{M365_SECURITY_URL}#{anchor}" if anchor else M365_SECURITY_URL
|
||||
|
||||
|
||||
# ============================================================
|
||||
# page fetch + parse
|
||||
# ============================================================
|
||||
@@ -117,6 +145,7 @@ def _parse_security_page(html: str) -> List[dict]:
|
||||
|
||||
Each release: {
|
||||
"date": "May 12, 2026",
|
||||
"anchor": "may-12-2026" or None, # the section's own heading id
|
||||
"channel_builds": {channel_name: [(major, rev), ...]}, # max = newest
|
||||
"cves": ["CVE-2026-40361", ...], # every CVE in the section
|
||||
}
|
||||
@@ -127,6 +156,9 @@ def _parse_security_page(html: str) -> List[dict]:
|
||||
# bullets, so segmenting the flattened text by date heading is robust
|
||||
# against markup churn.
|
||||
doc = lxml.html.fromstring(html)
|
||||
# Heading ids, read before anything is stripped: the deep link on a
|
||||
# finding uses the anchor the page actually has, never a guessed one.
|
||||
heading_ids = {str(i) for i in doc.xpath("//h2/@id | //h3/@id")}
|
||||
for bad in doc.xpath("//script | //style | //nav | //header | //footer"):
|
||||
bad.getparent().remove(bad)
|
||||
body = doc.xpath("//main") or [doc]
|
||||
@@ -156,8 +188,11 @@ def _parse_security_page(html: str) -> List[dict]:
|
||||
if not cves:
|
||||
continue
|
||||
|
||||
anchor = next((a for a in _anchor_candidates(date_label)
|
||||
if a in heading_ids), None)
|
||||
releases.append({
|
||||
"date": date_label,
|
||||
"anchor": anchor,
|
||||
"channel_builds": channel_builds,
|
||||
"cves": cves,
|
||||
})
|
||||
@@ -260,6 +295,7 @@ def detect_missing_cves(
|
||||
"latest_build": "19929.20162" or None, # newest patched, this channel
|
||||
"missing_cves": [ ... ], # union, deduped
|
||||
"behind_releases": [ "May 12, 2026", ... ],
|
||||
"cve_anchors": {"CVE-...": "may-12-2026"}, # section documenting it
|
||||
}
|
||||
"""
|
||||
out = {
|
||||
@@ -268,6 +304,7 @@ def detect_missing_cves(
|
||||
"latest_build": None,
|
||||
"missing_cves": [],
|
||||
"behind_releases": [],
|
||||
"cve_anchors": {},
|
||||
}
|
||||
installed = parse_build(installed_version)
|
||||
if not installed:
|
||||
@@ -293,10 +330,44 @@ def detect_missing_cves(
|
||||
if b and installed < b:
|
||||
cve_set.update(r.get("cves", []))
|
||||
out["behind_releases"].append(r.get("date"))
|
||||
# Remember which section documents each CVE, for the evidence
|
||||
# link on the finding. Releases come newest-first and Microsoft
|
||||
# occasionally re-lists a CVE in a later section, so the last
|
||||
# write — the OLDEST section — wins: that is where the fix was
|
||||
# first published.
|
||||
for c in r.get("cves", []):
|
||||
if r.get("anchor"):
|
||||
out["cve_anchors"][c.upper()] = r["anchor"]
|
||||
out["missing_cves"] = sorted(cve_set)
|
||||
return out
|
||||
|
||||
|
||||
def _attach_reference(vuln, url: Optional[str]) -> None:
|
||||
"""Point a finding at the Learn section it was raised from.
|
||||
|
||||
Our own previous link is replaced — Microsoft occasionally re-lists a CVE
|
||||
in a later section, and one page deserves one button — while references
|
||||
other sources wrote stay untouched.
|
||||
"""
|
||||
if not url:
|
||||
return
|
||||
if vuln.references and not vuln.reference_list:
|
||||
logger.warning("M365: unusable references value on %s (%r) — replaced",
|
||||
vuln.cve_id, vuln.references)
|
||||
vuln.references = None
|
||||
ours = [r for r in vuln.reference_list
|
||||
if r.split("#", 1)[0] == M365_SECURITY_URL and r != url]
|
||||
if ours:
|
||||
vuln.references = json.dumps(
|
||||
[r for r in vuln.reference_list if r not in ours])
|
||||
vuln.add_reference(url)
|
||||
|
||||
|
||||
def _reference_for(result: dict, cve_id: str) -> str:
|
||||
"""The evidence link for one CVE out of a detect_missing_cves result."""
|
||||
return security_page_url(result.get("cve_anchors", {}).get(cve_id.upper()))
|
||||
|
||||
|
||||
def upsert_m365_vulnerability(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -305,12 +376,17 @@ def upsert_m365_vulnerability(
|
||||
product_name: str,
|
||||
installed_version: str,
|
||||
fixed_build: Optional[str],
|
||||
reference_url: Optional[str] = None,
|
||||
) -> Tuple[Optional[int], bool]:
|
||||
"""Create/refresh a real-CVE M365 vuln row. Returns (id, was_created).
|
||||
|
||||
CVSS/severity are left as a neutral placeholder; the nightly
|
||||
enrichment (EPSS/KEV/NVD dates) and the Correct-CVSS job refine them.
|
||||
These are real CVE ids, so they enrich like any other CVE.
|
||||
|
||||
`reference_url` is the Learn security-updates section this finding was
|
||||
derived from — the only public record of the fix (see security_page_url).
|
||||
Passed on every run, so findings raised before it existed pick it up.
|
||||
"""
|
||||
from app.models.vulnerability import (
|
||||
Vulnerability, VulnerabilitySeverity, VulnerabilityStatus,
|
||||
@@ -345,6 +421,7 @@ def upsert_m365_vulnerability(
|
||||
existing.package_name = product_name[:255]
|
||||
existing.package_version = installed_version[:100]
|
||||
existing.fixed_version = (fixed_build or None)
|
||||
_attach_reference(existing, reference_url)
|
||||
from app.services.audit_events import reopen_if_patched
|
||||
reopen_if_patched(db, existing, reason="Microsoft 365 Apps check reports this CVE again", source="m365_check")
|
||||
existing.detected_at = datetime.now()
|
||||
@@ -369,6 +446,7 @@ def upsert_m365_vulnerability(
|
||||
sources='["microsoft365-apps"]',
|
||||
first_detected_by="m365_check",
|
||||
)
|
||||
_attach_reference(vuln, reference_url)
|
||||
db.add(vuln)
|
||||
db.flush()
|
||||
try:
|
||||
@@ -502,6 +580,7 @@ def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
|
||||
product_name=name,
|
||||
installed_version=version,
|
||||
fixed_build=result["latest_build"],
|
||||
reference_url=_reference_for(result, cve_id),
|
||||
)
|
||||
stats["cve_findings_total"] += 1
|
||||
touched_cves.add(cve_id.upper())
|
||||
@@ -662,6 +741,7 @@ def run_m365_for_packages(db: Session, asset, packages: list) -> int:
|
||||
upsert_m365_vulnerability(
|
||||
db, asset_id=asset.id, cve_id=cve_id, product_name=name,
|
||||
installed_version=version, fixed_build=result["latest_build"],
|
||||
reference_url=_reference_for(result, cve_id),
|
||||
)
|
||||
count += 1
|
||||
touched.add(cve_id.upper())
|
||||
|
||||
@@ -45,6 +45,12 @@ SYNTH_DESC_PREFIX = "MSRC reports "
|
||||
# night (an app-scan + MSRC refresh still produced no Edge findings).
|
||||
_INDEX_SETTING = "msrc_product_index_v2"
|
||||
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
|
||||
# How old the index may be for a SCAN to decide from it. The nightly chain
|
||||
# rebuilds at 01:30 and every scan that follows reuses that build; a scan
|
||||
# started by hand hours later rebuilds first, because the whole point of
|
||||
# pressing the button is to see today's CVEs and not last night's (see
|
||||
# ensure_index). 6h keeps the nightly 01:30 → 03:20 → 03:50 chain on one build.
|
||||
SCAN_INDEX_MAX_AGE = timedelta(hours=6)
|
||||
|
||||
# Curated map: asset side (`match_re`) → MSRC ProductTree name (`msrc_re`).
|
||||
#
|
||||
@@ -275,7 +281,8 @@ def _store_index(db: Session, index: dict) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
|
||||
def _load_blob(db: Session) -> Optional[tuple]:
|
||||
"""(index, built_at) as stored, or None when there is no usable cache."""
|
||||
from app.models.setting import Setting
|
||||
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
|
||||
if not row or not row.value:
|
||||
@@ -285,9 +292,47 @@ def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
|
||||
built = datetime.fromisoformat(blob.get("built_at"))
|
||||
except Exception:
|
||||
return None
|
||||
return (blob.get("index") or {}), built
|
||||
|
||||
|
||||
def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
|
||||
got = _load_blob(db)
|
||||
if not got:
|
||||
return None
|
||||
index, built = got
|
||||
if not allow_stale and datetime.now() - built > _INDEX_TTL:
|
||||
return None
|
||||
return blob.get("index") or {}
|
||||
return index
|
||||
|
||||
|
||||
def ensure_index(db: Session, max_age: timedelta = _INDEX_TTL) -> dict:
|
||||
"""The index, rebuilt first when it is missing or older than `max_age`.
|
||||
|
||||
A failed rebuild keeps the cached one — an offline night is no worse than
|
||||
before — and is logged at warning level, because a build that fails
|
||||
quietly leaves every consumer deciding from an old index for as long as
|
||||
it keeps failing (load_index serves it regardless of age).
|
||||
|
||||
Why the age matters: the index is what the app scan's package pass creates
|
||||
the Edge findings from, and MSRC publishes Edge CVEs on any weekday. An
|
||||
index rebuilt AFTER that scan (03:50 vs 03:20) put every Edge CVE a full
|
||||
night late — published day D, on the host D+2 (observed: CVE-2026-84324,
|
||||
published 03.09. 17:00, still "app-scan only" on 04.09.). The 01:30 index
|
||||
refresh rebuilds it now; this helper lets the 03:50 job reuse that build
|
||||
instead of pulling the 18 documents a second time.
|
||||
"""
|
||||
got = _load_blob(db)
|
||||
if got and datetime.now() - got[1] <= max_age:
|
||||
return got[0]
|
||||
try:
|
||||
fresh = build_product_index(db)
|
||||
except Exception as e:
|
||||
if got:
|
||||
logger.warning("msrc-scan: index rebuild failed, serving the cached one "
|
||||
"from %s: %s", got[1].isoformat(timespec="minutes"), e)
|
||||
return got[0]
|
||||
raise
|
||||
return fresh or (got[0] if got else {})
|
||||
|
||||
|
||||
# ---------- scan ----------
|
||||
@@ -662,22 +707,28 @@ def run_msrc_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
from app.models.asset import Asset
|
||||
|
||||
stats = {"assets": 0, "findings": 0, "new": 0, "resolved": 0, "errors": []}
|
||||
index = load_index(db)
|
||||
if not index:
|
||||
logger.info("msrc-scan: index missing — building now (one-time, then nightly)")
|
||||
try:
|
||||
index = build_product_index(db) or {}
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"index build failed: {e}")
|
||||
return stats
|
||||
# Not load_index(): that serves the stored blob at ANY age, so a scan run
|
||||
# by hand decided from whenever the last nightly build happened and could
|
||||
# never surface a CVE published since. A failed rebuild still serves the
|
||||
# cache, so an offline MSRC costs nothing this run.
|
||||
try:
|
||||
index = ensure_index(db, max_age=SCAN_INDEX_MAX_AGE) or {}
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"index build failed: {e}")
|
||||
return stats
|
||||
if not index:
|
||||
return stats
|
||||
|
||||
from app.services.audit_events import asset_inventory, use_inventory
|
||||
|
||||
new_ids: list = []
|
||||
q = db.query(Asset)
|
||||
if asset_id is not None:
|
||||
q = q.filter(Asset.id == asset_id)
|
||||
for asset in q.all():
|
||||
# This pass compares asset.os_version, which an inventory sync wrote —
|
||||
# so a reopen from it inherits that inventory's lag, not "live".
|
||||
use_inventory(asset_inventory(asset))
|
||||
prod = resolve_os(asset.operating_system or "")
|
||||
if not prod:
|
||||
continue
|
||||
@@ -693,6 +744,7 @@ def run_msrc_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
stats["errors"].append(f"asset {asset.id}: {e}")
|
||||
use_inventory(None)
|
||||
|
||||
stats["new"] = len(new_ids)
|
||||
if new_ids:
|
||||
|
||||
@@ -205,6 +205,34 @@ def _backfill_description(db: Session, cve_id: str, text: str) -> int:
|
||||
return n
|
||||
|
||||
|
||||
_MS_SEVERITY_RANK = {"low": 1, "moderate": 2, "important": 3, "critical": 4}
|
||||
|
||||
|
||||
def _max_ms_severity(words) -> Optional[str]:
|
||||
"""Highest word on Microsoft's scale, title-cased — what the Update Guide
|
||||
shows as "Max Severity". Anything off the scale is ignored."""
|
||||
best = max((str(w or "").strip().lower() for w in words),
|
||||
key=lambda w: _MS_SEVERITY_RANK.get(w, 0), default="")
|
||||
return best.title() if best in _MS_SEVERITY_RANK else None
|
||||
|
||||
|
||||
def _cvrf_ms_severity(v: dict) -> Optional[str]:
|
||||
"""CVRF Threats of Type 3 carry the severity, one per product group."""
|
||||
return _max_ms_severity(
|
||||
(t.get("Description") or {}).get("Value")
|
||||
for t in v.get("Threats") or [] if t.get("Type") == 3)
|
||||
|
||||
|
||||
def _set_ms_severity(db: Session, cve_id: str, rating: Optional[str]) -> int:
|
||||
"""Stamp Microsoft's rating on every finding for the CVE, whichever
|
||||
scanner found it. Follows revisions both ways."""
|
||||
if not rating:
|
||||
return 0
|
||||
from app.models.vulnerability import Vulnerability
|
||||
return sum(v.set_vendor_severity("Microsoft", rating)
|
||||
for v in db.query(Vulnerability).filter(Vulnerability.cve_id == cve_id).all())
|
||||
|
||||
|
||||
def _upsert_cve(db: Session, cve_id: str, rows: List[dict]) -> None:
|
||||
"""Replace all msrc rows for a cve_id with the freshly parsed set."""
|
||||
db.query(CveRemediation).filter(
|
||||
@@ -241,7 +269,7 @@ def refresh_msrc(db: Session, months_back: Optional[int] = None,
|
||||
}
|
||||
|
||||
stats = {"docs": 0, "cves_seen": 0, "cves_stored": 0, "rows": 0,
|
||||
"descriptions": 0, "errors": []}
|
||||
"descriptions": 0, "ms_severity": 0, "errors": []}
|
||||
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0"}) as client:
|
||||
try:
|
||||
@@ -271,6 +299,7 @@ def refresh_msrc(db: Session, months_back: Optional[int] = None,
|
||||
desc = _cve_description(v)
|
||||
if desc:
|
||||
stats["descriptions"] += _backfill_description(db, cve, desc)
|
||||
stats["ms_severity"] += _set_ms_severity(db, cve, _cvrf_ms_severity(v))
|
||||
rows = parse_vulnerability(v)
|
||||
if not rows:
|
||||
continue
|
||||
@@ -328,8 +357,10 @@ def enrich_cve_msrc(db: Session, cve_id: str) -> int:
|
||||
logger.debug("MSRC on-demand fetch failed for %s: %s", cve_id, e)
|
||||
return 0
|
||||
|
||||
_set_ms_severity(db, cve_id, _max_ms_severity(p.get("severity") for p in products))
|
||||
rows = rows_from_sug(products)
|
||||
if not rows:
|
||||
db.commit()
|
||||
_NOT_IN_MSRC.add(cve_id)
|
||||
return 0
|
||||
_upsert_cve(db, cve_id, rows)
|
||||
|
||||
+16
-16
@@ -31,6 +31,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.integrations.nessus_client import NessusClient
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus
|
||||
from app.services.asset_matching import match_by_hostname, match_by_ip
|
||||
from app.models.scan import Scan, ScanStatus, ScanType
|
||||
from app.models.setting import Setting
|
||||
from app.models.vulnerability import (
|
||||
@@ -112,11 +113,8 @@ def _find_or_create_asset(
|
||||
if short and short != hostname:
|
||||
candidates.append(short)
|
||||
for candidate in candidates:
|
||||
a = (
|
||||
db.query(Asset)
|
||||
.filter(Asset.hostname.ilike(candidate))
|
||||
.first()
|
||||
)
|
||||
a = match_by_hostname(db, candidate, pin_col=Asset.nessus_host_uuid,
|
||||
pin_value=host_uuid, ip=ip)
|
||||
if a:
|
||||
if host_uuid and not a.nessus_host_uuid:
|
||||
a.nessus_host_uuid = host_uuid
|
||||
@@ -128,11 +126,8 @@ def _find_or_create_asset(
|
||||
# assets whose hostname starts with 'short.' so we still
|
||||
# consolidate the right way around.
|
||||
if short:
|
||||
a = (
|
||||
db.query(Asset)
|
||||
.filter(Asset.hostname.ilike(f"{short}.%"))
|
||||
.first()
|
||||
)
|
||||
a = match_by_hostname(db, f"{short}.%", pin_col=Asset.nessus_host_uuid,
|
||||
pin_value=host_uuid, ip=ip)
|
||||
if a:
|
||||
if host_uuid and not a.nessus_host_uuid:
|
||||
a.nessus_host_uuid = host_uuid
|
||||
@@ -142,7 +137,7 @@ def _find_or_create_asset(
|
||||
|
||||
# 3) IP
|
||||
if ip:
|
||||
a = db.query(Asset).filter(Asset.ip_address == ip).first()
|
||||
a = match_by_ip(db, ip, hostname)
|
||||
if a:
|
||||
if host_uuid and not a.nessus_host_uuid:
|
||||
a.nessus_host_uuid = host_uuid
|
||||
@@ -165,6 +160,8 @@ def _find_or_create_asset(
|
||||
"Nessus sync: auto-created asset %s (from FQDN '%s', ip %s)",
|
||||
a.hostname, hostname, a.ip_address,
|
||||
)
|
||||
from app.services.asset_lifecycle import created
|
||||
created(db, a, "Nessus")
|
||||
return a, "created"
|
||||
|
||||
return None, "skipped"
|
||||
@@ -714,11 +711,14 @@ def run_nessus_sync(
|
||||
):
|
||||
existing.description = full_description
|
||||
changed = True
|
||||
# references: append Nessus see_also URLs if the
|
||||
# column is empty.
|
||||
if n_see_also and not existing.references:
|
||||
existing.references = json.dumps(n_see_also)
|
||||
changed = True
|
||||
# references: add Nessus see_also URLs. Merged,
|
||||
# not fill-only: other sources write this column
|
||||
# too (the M365 check attaches its release-notes
|
||||
# section), and a non-empty column must not cost
|
||||
# the host its vendor advisory.
|
||||
for _url in (n_see_also or []):
|
||||
if existing.add_reference(_url):
|
||||
changed = True
|
||||
# Remediation precedence: a SPECIFIC solution
|
||||
# always beats a generic/empty one; a generic
|
||||
# solution never overwrites a specific one. Fixes
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
"""Netdisco inventory sync.
|
||||
|
||||
Registers every device Netdisco has discovered as an asset (source=NETDISCO),
|
||||
then runs CVE detection on the ones whose firmware line is covered — HPE Aruba,
|
||||
Cisco IOS XE / IOS XR, Extreme Networks EXOS, Ubiquiti UniFi access points and
|
||||
Brocade Fabric OS — the same find-or-create + lifecycle-reconcile pattern as the
|
||||
Intune, Nessus, vCenter and IGEL syncs. NetScaler ADC/Gateway is the sixth line,
|
||||
Kemp LoadMaster the seventh, Sophos Firewall the eighth.
|
||||
|
||||
Why this exists: a switch is the one asset class nothing else in this dashboard
|
||||
can see. It runs no Wazuh agent (you do not install one on switch firmware),
|
||||
no MDM enrols it, and a Nessus scan without SNMP credentials gets an open SSH
|
||||
port and no version. So an estate of access switches — the devices every other
|
||||
asset is plugged into — showed up as zero assets and zero findings; not
|
||||
"clean", just unlooked-at. Netdisco already holds the exact inventory needed:
|
||||
it asks each device over SNMP and records the firmware version.
|
||||
|
||||
The firmware version is the point, and each vendor needs its own
|
||||
normalisation. HPE prefixes
|
||||
the build with a two-letter CODE LINE naming the hardware family the image is
|
||||
for — "WC.16.11.0016" on a 2930F, "YA.16.11.0027" on a 2530, "PL.10.13.1005"
|
||||
on a CX 6300. The letters are not part of the version; HPE's own advisories
|
||||
bound the numbers only. See cvelistv5_scan_service.aruba_version.
|
||||
|
||||
Cisco is the opposite case: its trailing letters ARE the version. "17.15.4c" is
|
||||
a different release from "17.15.4", and both sources enumerate them as separate
|
||||
affected entries, so the Cisco match is exact string membership and never a
|
||||
comparison. All that is normalised there is case and zero-padding — Cisco's own
|
||||
release notes write one build as both "17.09.05a" and "17.9.5a", and only the
|
||||
second spelling exists in NVD and cvelistV5. See
|
||||
cvelistv5_scan_service.cisco_version and scan_asset_cisco.
|
||||
|
||||
Extreme is the plain case of the three: EXOS versions are dotted numbers and
|
||||
Extreme states proper floored ranges for them, so the only normalisation is
|
||||
dropping what SNMP::Info read them out of — the sysDescr sentence, and the
|
||||
"-patch1-11" tag a patch build carries, which neither source ever states as a
|
||||
bound. See cvelistv5_scan_service.extreme_version and scan_asset_extreme.
|
||||
|
||||
Ubiquiti is the case where the os slug is worthless: a UniFi row answers it as
|
||||
"U6-LR", "U7-Outdoor" or plain "UniFi" depending on what sysDescr said. The
|
||||
MODEL is the column that decides, and it decides twice — once whether the row
|
||||
is an access point at all (AP, switch and gateway firmware share one numbering
|
||||
scheme, 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. The model therefore rides in the asset's OS string. See
|
||||
ubiquiti_family() and cvelistv5_scan_service.scan_asset_ubiquiti.
|
||||
|
||||
Brocade is the only line covered from NVD ALONE. Its CNA writes 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. Fabric OS also letters its rebuilds ("9.0.1e1",
|
||||
"8.2.3e") and, unlike Cisco, states real RANGES over them, so the letters have
|
||||
to be ordered rather than compared for equality. See brocade_family() and
|
||||
app_cve_scanner_service._vparse.
|
||||
|
||||
NetScaler writes a release line and a build, "14.1-72.61", and the CNA bounds
|
||||
the build within the line. Its FIPS and NDcPP builds are separate lines that the
|
||||
inventory cannot tell apart, so those build ranges get no verdict. See
|
||||
netscaler_family() and cvelistv5_scan_service.scan_asset_netscaler.
|
||||
|
||||
LoadMaster reads "7.2.63.3.23e1735.RELEASE.20260625-1143"; the first four numbers
|
||||
are the release. Its GA and long-term lines share one numbering and every advisory
|
||||
names a fix per line, so the decision is per line. See loadmaster_family() and
|
||||
cvelistv5_scan_service.scan_asset_loadmaster.
|
||||
|
||||
Sophos Firewall reads "22.0.1 MR-1-Build490". Both sources bound the MR, but
|
||||
Sophos hotfixes older releases in place without changing that string, so the
|
||||
releases each advisory hotfixed are held in code and get no verdict. See
|
||||
sophos_family() and cvelistv5_scan_service.scan_asset_sophos.
|
||||
|
||||
What is NOT here, deliberately:
|
||||
|
||||
* Vendor security bulletins. HPE publishes its own (hpesbnw*), and they are
|
||||
the same data the CNA records already carry in structured form — the two
|
||||
CVE sources cover the current advisories between them, so a bulletin
|
||||
scraper would add a third opinion and no new CVE. If a future advisory ever
|
||||
turns out to exist nowhere else, that is when to add one.
|
||||
* End-of-maintenance dates. EOM is the finding that matters most for a switch
|
||||
— a firmware line that no longer receives security fixes cannot be patched
|
||||
out of anything — but HPE publishes those dates per PRODUCT (the hardware),
|
||||
in a support-lifecycle portal, and nowhere machine-readable per firmware
|
||||
line. endoflife.date carries no ArubaOS product at all. An invented date on
|
||||
a switch estate would retire real assets on a guess, so nothing is claimed
|
||||
until a source that states EOM per OS release is found.
|
||||
* Devices outside the covered firmware lines get an asset, an OS string and
|
||||
no CVE verdict. Cumulus, NX-OS and the rest need their own curated
|
||||
matching; inventing one from a generic version comparison is how a scanner
|
||||
produces a page of findings that are all wrong.
|
||||
* Extreme's Fabric Engine (VOSS). Both sources carry its CVEs, but no
|
||||
Netdisco row can be told apart from an Avaya/Nortel ERS switch — see
|
||||
extreme_family() for why, and why guessing would flag the whole ERS
|
||||
estate.
|
||||
* Ubiquiti's UniFi switches, gateways and cameras. NVD carries a separate
|
||||
`unifi_switch_firmware` CPE and Ubiquiti a separate "UniFi Switches"
|
||||
product, so the data is there — but no such device was in the estate this
|
||||
was built against, and a key nothing fills is a key nothing tests. The
|
||||
model gate in ubiquiti_family() already tells them apart; adding the key is
|
||||
a few lines the day one appears.
|
||||
* Brocade's IP switches (ICX/FastIron, NetIron). Same vendor string, same
|
||||
dotted-plus-letter version shape, completely different firmware — kept out
|
||||
by the os slug, which is the only thing that separates them.
|
||||
* Classic Cisco IOS (12.x/15.x). It numbers releases "15.2(7)E3" — NVD
|
||||
backslash-escapes the parentheses into the CPE and no version scheme here
|
||||
reads that. IOS XE and IOS XR are covered; a classic-IOS device gets the
|
||||
asset only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus
|
||||
from app.services.asset_matching import (different_hosts, match_by_hostname, match_by_ip,
|
||||
stable_serial as _stable_serial)
|
||||
from app.services import cvelistv5_scan_service as c5
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SETTING_KEY = "netdisco_config"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Which Aruba family a Netdisco row is
|
||||
# ------------------------------------------------------------------
|
||||
# Netdisco's `os` column is SNMP::Info's os() — a slug, not a product name:
|
||||
# arubaos-cx SNMP::Info::Layer3::ArubaCX → AOS-CX (6000/6300/8300 …)
|
||||
# hp SNMP::Info::Layer2::HP → the ProVision line (2530, 2930F,
|
||||
# 3810, 5400R) — "ArubaOS-Switch"
|
||||
# aos-w/airos SNMP::Info::Layer3::Aruba → Mobility controllers, ArubaOS
|
||||
# so the family cannot be read off the string alone: "hp" is also what a
|
||||
# non-switch HP device answers, and "airos" is what Ubiquiti's AirOS is called
|
||||
# too. Each family therefore has to agree on THREE things — the os slug, the
|
||||
# vendor, and the shape of the version — before anything is claimed.
|
||||
_CX_OS_RE = re.compile(r"^(arubaos|aos)[-_ ]?cx$|^aruba[-_ ]?cx$", re.I)
|
||||
_AOSS_OS_RE = re.compile(r"^(hp|hpe|procurve|hp[-_ ]?procurve|"
|
||||
r"(arubaos|aos)[-_ ]?s(witch)?)$", re.I)
|
||||
_AOSW_OS_RE = re.compile(r"^(aos[-_ ]?w|airos|arubaos|aruba)$", re.I)
|
||||
|
||||
# Vendor, as Netdisco spells it (SNMP::Info's vendor(), lowercase slug).
|
||||
_ARUBA_VENDORS = {"aruba", "arubanetworks", "aruba networks", "hp", "hpe",
|
||||
"hewlett packard", "hewlett-packard",
|
||||
"hewlett packard enterprise", "alcatel-lucent"}
|
||||
|
||||
# ProVision majors. 15.x and 16.x are the lines HPE bounds its AOS-S advisories
|
||||
# by and the only ones NVD carries versions for; anything older is a ProCurve
|
||||
# from before the numbering (H.10.98) that no current advisory mentions. The
|
||||
# check is what keeps a non-switch "hp" device — which answers the same os slug
|
||||
# with some other version scheme entirely — out of the switch family.
|
||||
_AOSS_MAJORS = {15, 16}
|
||||
|
||||
|
||||
def aruba_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
|
||||
"""Netdisco (os, vendor, version) → curated Aruba product key, or None.
|
||||
|
||||
None means "not an Aruba device we can decide", and it is the answer for
|
||||
everything this connector cannot match precisely — that asset still exists,
|
||||
it simply carries no CVE verdict, which is the honest state.
|
||||
"""
|
||||
os_slug = (os_slug or "").strip()
|
||||
vendor_lc = (vendor or "").strip().lower()
|
||||
num = c5.aruba_version(version)
|
||||
parts = c5.cpe._vtuple(num) if num else None
|
||||
if not parts or len(parts) < 2:
|
||||
return None
|
||||
if _CX_OS_RE.match(os_slug):
|
||||
# AOS-CX writes three components, "10.13.1005". A two-component answer
|
||||
# is a truncated read, not a version we can bound.
|
||||
return "aruba-cx" if len(parts) >= 3 else None
|
||||
if _AOSS_OS_RE.match(os_slug) and vendor_lc in _ARUBA_VENDORS:
|
||||
return ("aruba-switch"
|
||||
if parts[0] in _AOSS_MAJORS and len(parts) >= 3 else None)
|
||||
if _AOSW_OS_RE.match(os_slug) and vendor_lc in _ARUBA_VENDORS:
|
||||
# Mobility AOS writes four components, "8.13.1.1" / "10.7.2.2". The
|
||||
# count is what separates a controller on 10.7.2.2 from a CX switch on
|
||||
# 10.13.1005 if the os slug is ever ambiguous — and it is what keeps a
|
||||
# Ubiquiti AirOS device (same "airos" slug, another vendor entirely)
|
||||
# from being read as an Aruba controller.
|
||||
return "aruba-os" if len(parts) >= 4 else None
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Which Cisco firmware line a Netdisco row is
|
||||
# ------------------------------------------------------------------
|
||||
# SNMP::Info's os() slug again, and the same three-way agreement as Aruba —
|
||||
# slug, vendor and the shape of the version — before anything is claimed.
|
||||
# ios-xe SNMP::Info::Layer3::CiscoSwitch etc. → IOS XE (Catalyst 9k, ISR,
|
||||
# ASR 1k, C1121X …)
|
||||
# ios-xr SNMP::Info::Layer3::CiscoXR → IOS XR (ASR 9k, NCS)
|
||||
# ios classic IOS — NOT covered, see the module docstring
|
||||
_XE_OS_RE = re.compile(r"^ios[-_ ]?xe$", re.I)
|
||||
_XR_OS_RE = re.compile(r"^ios[-_ ]?xr$", re.I)
|
||||
|
||||
_CISCO_VENDORS = {"cisco", "cisco systems", "ciscosystems"}
|
||||
|
||||
|
||||
def cisco_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
|
||||
"""Netdisco (os, vendor, version) → curated Cisco product key, or None.
|
||||
|
||||
The version must carry at least three numeric components, which is what
|
||||
every IOS XE and IOS XR release has ever had ("17.15.4c", "3.2.0SG",
|
||||
"7.11.2"). A two-component answer is a truncated sysDescr read, and since
|
||||
the match downstream is EXACT it would simply never hit — better to say
|
||||
"no verdict" here than to scan with a release that cannot exist.
|
||||
"""
|
||||
os_slug = (os_slug or "").strip()
|
||||
vendor_lc = (vendor or "").strip().lower()
|
||||
if vendor_lc not in _CISCO_VENDORS:
|
||||
return None
|
||||
num = c5.cisco_version(version)
|
||||
parts = c5.cpe._vtuple(num) if num else None
|
||||
if not parts or len(parts) < 3:
|
||||
return None
|
||||
if _XE_OS_RE.match(os_slug):
|
||||
return "cisco-ios-xe"
|
||||
if _XR_OS_RE.match(os_slug):
|
||||
return "cisco-ios-xr"
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Which Extreme firmware line a Netdisco row is
|
||||
# ------------------------------------------------------------------
|
||||
# SNMP::Info::Layer3::Extreme answers vendor() as the literal "extreme" and
|
||||
# os() from sysDescr — "xos" when the description mentions XOS, "extremeware"
|
||||
# otherwise. Only the first is covered here; ExtremeWare is the 2010-era
|
||||
# firmware of the same company and a different product line, and NVD's
|
||||
# `extremeware_xos` bounds stop at 11.1.
|
||||
#
|
||||
# Fabric Engine (VOSS) is NOT reachable from this connector, and that is an
|
||||
# INVENTORY limit rather than a data one — both sources carry its CVEs
|
||||
# (CVE-2025-11192 has a CPE and a CNA range). 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 at all. Nothing else on the row separates them,
|
||||
# and "before 9.3" handed to an ERS numbered 5.x matches every one of them. The
|
||||
# day a row can be told apart, the key is a few lines.
|
||||
_EXOS_OS_RE = re.compile(r"^(x|e|extreme\s*)?xos$", re.I)
|
||||
|
||||
_EXTREME_VENDORS = {"extreme", "extreme networks", "extremenetworks"}
|
||||
|
||||
|
||||
def extreme_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
|
||||
"""Netdisco (os, vendor, version) → the curated EXOS key, or None.
|
||||
|
||||
Three-way agreement again — slug, vendor and the shape of the version. The
|
||||
version must carry at least three numeric components: EXOS writes four
|
||||
("31.7.2.4", "22.7.1.1-patch1-11") and Extreme's own bounds three or four
|
||||
("31.7.4", "32.7.4.15"), so a two-component answer is a truncated sysDescr
|
||||
read. Compared anyway it would sit below every bound in the record and
|
||||
collect the lot — the one direction this connector never guesses in.
|
||||
"""
|
||||
if (vendor or "").strip().lower() not in _EXTREME_VENDORS:
|
||||
return None
|
||||
if not _EXOS_OS_RE.match((os_slug or "").strip()):
|
||||
return None
|
||||
num = c5.extreme_version(version)
|
||||
parts = c5.cpe._vtuple(num) if num else None
|
||||
return "extreme-exos" if parts and len(parts) >= 3 else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Which Ubiquiti UniFi device a Netdisco row is
|
||||
# ------------------------------------------------------------------
|
||||
# The one vendor here whose os slug decides NOTHING. SNMP::Info reports a UniFi
|
||||
# device's os() as whatever sysDescr called it — the estate in the field report
|
||||
# answered "U6-LR", "U7-Outdoor" and plain "UniFi" across rows of the same
|
||||
# firmware line — so the slug is sometimes the model, sometimes the brand, and
|
||||
# never the product line. The MODEL column is what separates the product lines,
|
||||
# and it has to, because Ubiquiti numbers AP, switch and gateway firmware in
|
||||
# one shared scheme: CVE-2023-38034 fixes the APs in 6.5.53 and the switches in
|
||||
# 6.5.32. A USW-24 judged against the AP bounds is a finding on the wrong host.
|
||||
#
|
||||
# So: vendor, model, and the shape of the version — the same three-way
|
||||
# agreement as everywhere else, with model standing in for the slug.
|
||||
_UBIQUITI_VENDORS = {"ubiquiti", "ubiquiti networks", "ubiquiti inc",
|
||||
"ubnt", "ui", "ui.com"}
|
||||
|
||||
# Access-point models, and only those. Ubiquiti's AP range is "UAP-*" (the
|
||||
# older AC generation), "U6-*"/"U7-*" (WiFi 6/6E/7) and the two bridges,
|
||||
# UBB and UWB-XG. `u\d` rather than a fixed list of generations so a U8 or U9
|
||||
# is covered the day it ships — nothing else Ubiquiti makes starts with a
|
||||
# letter-then-digit: switches are "US-*"/"USW-*", gateways "UDM"/"USG"/"UXG",
|
||||
# cameras "UVC-*"/"G4-*", and none of them match.
|
||||
_UAP_MODEL_RE = re.compile(r"^(uap|ubb|uwb|u\d+)([-+ ]|$)", re.I)
|
||||
|
||||
|
||||
def ubiquiti_family(vendor: str, model: str, version: str) -> Optional[str]:
|
||||
"""Netdisco (vendor, model, version) → the curated UniFi AP key, or None.
|
||||
|
||||
Three numeric components required, which every UniFi firmware release has
|
||||
("6.7.58", "8.8.7") and every bound in both sources is written as. A
|
||||
two-component answer is a truncated read, and compared anyway it would sit
|
||||
below every bound in the record and collect the lot.
|
||||
"""
|
||||
if (vendor or "").strip().lower() not in _UBIQUITI_VENDORS:
|
||||
return None
|
||||
if not _UAP_MODEL_RE.match((model or "").strip()):
|
||||
return None
|
||||
parts = c5.cpe._vtuple(c5.cpe._clean_version(version or "") or "")
|
||||
return "ubiquiti-uap" if parts and len(parts) >= 3 else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Which Brocade firmware line a Netdisco row is
|
||||
# ------------------------------------------------------------------
|
||||
# Here the os slug is load-bearing again, and it is the ONLY thing that keeps
|
||||
# two unrelated firmware lines apart. Brocade sells FC SAN switches running
|
||||
# Fabric OS and IP switches running FastIron/NetIron, both under the same
|
||||
# vendor string and both numbered in the same dotted-plus-letter shape
|
||||
# ("9.0.1e1" against "08.0.95hT211"). SNMP::Info classes them separately —
|
||||
# Layer2::Brocade answers os "brocade" for the FC switches, the Foundry classes
|
||||
# answer "ironware"/"fastiron"/"foundry" for the IP ones — so the slug is what
|
||||
# stops a Fabric OS bound being handed to an ICX.
|
||||
_FABOS_OS_RE = re.compile(r"^(brocade|fab(ric)?[-_ ]?os)$", re.I)
|
||||
|
||||
_BROCADE_VENDORS = {"brocade", "broadcom", "brocade communications systems",
|
||||
"brocade communications"}
|
||||
|
||||
# The canonical label side of the same question, for an asset that already
|
||||
# exists. Brocade has no cvelistV5 key to borrow one from — its CNA writes the
|
||||
# bounds as English prose that no range collector can read — so this line lives
|
||||
# here rather than in cvelistv5_scan_service.
|
||||
FABOS_LABEL = "Brocade Fabric OS"
|
||||
_FABOS_LABEL_RE = re.compile(r"^(brocade\s+|broadcom\s+)?fabric\s*os\b", re.I)
|
||||
|
||||
|
||||
def brocade_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
|
||||
"""Netdisco (os, vendor, version) → the curated Fabric OS key, or None.
|
||||
|
||||
The version is read with the LETTERED reader, not the strict one: Fabric OS
|
||||
numbers its rebuilds "9.0.1e1", "8.2.3e", "9.2.1c3" and both NVD's bounds
|
||||
and Brocade's own prose are written the same way. Read strictly the version
|
||||
is not a version at all, which would mean no scan.
|
||||
"""
|
||||
if (vendor or "").strip().lower() not in _BROCADE_VENDORS:
|
||||
return None
|
||||
if not _FABOS_OS_RE.match((os_slug or "").strip()):
|
||||
return None
|
||||
num = c5.cpe._lettered_version(version or "")
|
||||
parts = c5.cpe._vtuple(num) if num else None
|
||||
return "brocade-fabricos" if parts and len(parts) >= 3 else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Which NetScaler a Netdisco row is
|
||||
# ------------------------------------------------------------------
|
||||
# Observed row: vendor "citrix", os "netscaler", model "Netscaler Remote
|
||||
# Licensed Virtual Appliance", version "14.1-72.61". Slug, vendor and the shape
|
||||
# of the version again. A model or version naming FIPS or NDcPP is withheld
|
||||
# outright; the build-number guard in netscaler_build covers the rows that do
|
||||
# not say so.
|
||||
_NETSCALER_OS_RE = re.compile(r"^netscaler([-_ ]?(adc|gateway))?$", re.I)
|
||||
_NETSCALER_VENDORS = {"citrix", "citrix systems", "netscaler", "cloud software group"}
|
||||
_FIPS_RE = re.compile(r"fips|ndcpp", re.I)
|
||||
|
||||
|
||||
def netscaler_family(os_slug: str, vendor: str, model: str, version: str) -> Optional[str]:
|
||||
"""Netdisco (os, vendor, model, version) → the curated NetScaler key, or None."""
|
||||
if (vendor or "").strip().lower() not in _NETSCALER_VENDORS:
|
||||
return None
|
||||
if not _NETSCALER_OS_RE.match((os_slug or "").strip()):
|
||||
return None
|
||||
if _FIPS_RE.search(f"{model or ''} {version or ''}"):
|
||||
return None
|
||||
return "netscaler-adc" if c5.netscaler_build(version) else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Which LoadMaster a Netdisco row is
|
||||
# ------------------------------------------------------------------
|
||||
# Observed rows (enterprise 12196): vendor "Kemp", os "LoadMaster" before
|
||||
# Netdisco had a LoadMaster device class; vendor "kemp", os "LMOS" (LoadMaster
|
||||
# OS) since. Model "LoadMaster" and version
|
||||
# "7.2.63.3.23e1735.RELEASE.20260625-1143" in both.
|
||||
_LOADMASTER_VENDORS = {"kemp", "kemp technologies", "progress", "progress software"}
|
||||
_LOADMASTER_OS_SLUGS = {"loadmaster", "lmos"}
|
||||
|
||||
|
||||
def loadmaster_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
|
||||
"""Netdisco (os, vendor, version) → the curated LoadMaster key, or None."""
|
||||
if (vendor or "").strip().lower() not in _LOADMASTER_VENDORS:
|
||||
return None
|
||||
if (os_slug or "").strip().lower() not in _LOADMASTER_OS_SLUGS:
|
||||
return None
|
||||
return "kemp-loadmaster" if c5.loadmaster_version(version) else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Which Sophos Firewall a Netdisco row is
|
||||
# ------------------------------------------------------------------
|
||||
# Observed row: vendor "sophos", os "sfos", model "XGS…", version
|
||||
# "22.0.1 MR-1-Build490".
|
||||
def sophos_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
|
||||
"""Netdisco (os, vendor, version) → the curated Sophos Firewall key, or None."""
|
||||
if (vendor or "").strip().lower() != "sophos" or (os_slug or "").strip().lower() != "sfos":
|
||||
return None
|
||||
return "sophos-firewall" if c5.sophos_version(version) else None
|
||||
|
||||
|
||||
def firmware_family(d: dict) -> Optional[str]:
|
||||
"""The curated product key for a device row, across every covered vendor.
|
||||
|
||||
One place to ask "can this device get a CVE verdict at all?", so the sync,
|
||||
the connection test and the stats do not each carry their own list.
|
||||
"""
|
||||
args = (d.get("os", ""), d.get("vendor", ""), d.get("os_version", ""))
|
||||
return (aruba_family(*args) or cisco_family(*args)
|
||||
or extreme_family(*args) or brocade_family(*args)
|
||||
or ubiquiti_family(d.get("vendor", ""), d.get("model", ""),
|
||||
d.get("os_version", ""))
|
||||
or netscaler_family(d.get("os", ""), d.get("vendor", ""),
|
||||
d.get("model", ""), d.get("os_version", ""))
|
||||
or loadmaster_family(*args) or sophos_family(*args))
|
||||
|
||||
|
||||
def os_and_version(d: dict) -> tuple:
|
||||
"""Device row → (operating_system, os_version) to write onto the asset.
|
||||
|
||||
For a device on a covered firmware line that is the canonical family label
|
||||
and the NORMALISED version — both CVE paths key off exactly those, and
|
||||
neither "WC.16.11.0016" nor "17.09.05A" is a string either source states.
|
||||
|
||||
For everything else it is Netdisco's own two slugs, joined: "cisco ios",
|
||||
"cumulus linux". Joined and not bare, because a bare "ios" is a product
|
||||
name that belongs to somebody else — see the anchored Apple entry in
|
||||
app_cve_scanner_service._OS_REGISTRY. Note that a classic-IOS device lands
|
||||
here, on purpose: "cisco ios" matches neither the Apple pattern nor the
|
||||
anchored "cisco ios xe"/"cisco ios xr" ones, so it gets an asset and no
|
||||
verdict.
|
||||
"""
|
||||
key = aruba_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", ""))
|
||||
if key:
|
||||
return c5.ARUBA_LABELS[key], c5.aruba_version(d.get("os_version"))
|
||||
if extreme_family(d.get("os", ""), d.get("vendor", ""),
|
||||
d.get("os_version", "")):
|
||||
# The plain release, without the sentence SNMP::Info may have read it
|
||||
# out of and without a "-patch1-11" tag neither source states.
|
||||
return c5.EXTREME_EXOS_LABEL, c5.extreme_version(d.get("os_version"))
|
||||
key = cisco_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", ""))
|
||||
if key:
|
||||
# Normalised to the spelling BOTH sources use — lowercased, no
|
||||
# zero-padding — because the Cisco match is exact and "17.09.05A" and
|
||||
# "17.9.5a" are the same release written two ways.
|
||||
return c5.CISCO_LABELS[key], c5.cisco_version(d.get("os_version"))
|
||||
if brocade_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", "")):
|
||||
# The lettered reader, not the strict one — the rebuild letter is part
|
||||
# of the Fabric OS release and every bound is written with it.
|
||||
return (FABOS_LABEL, c5.cpe._lettered_version(d.get("os_version") or ""))
|
||||
if ubiquiti_family(d.get("vendor", ""), d.get("model", ""),
|
||||
d.get("os_version", "")):
|
||||
# The MODEL is carried in the label, and it is not decoration: half of
|
||||
# Ubiquiti's AP records name one model and apply to that model alone
|
||||
# (CVE-2024-37380 is U6+ only). scan_asset_ubiquiti reads it back out.
|
||||
return (f"{c5.UBIQUITI_UAP_LABEL} {(d.get('model') or '').strip()}".strip(),
|
||||
c5.cpe._clean_version(d.get("os_version") or ""))
|
||||
if netscaler_family(d.get("os", ""), d.get("vendor", ""), d.get("model", ""),
|
||||
d.get("os_version", "")):
|
||||
# The build as written: both sources bound "14.1-72.61" in that shape.
|
||||
return c5.NETSCALER_LABEL, (d.get("os_version") or "").strip()
|
||||
if loadmaster_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", "")):
|
||||
# The release only; neither source bounds the build hash or date.
|
||||
return c5.KEMP_LOADMASTER_LABEL, c5.loadmaster_version(d.get("os_version"))
|
||||
if sophos_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", "")):
|
||||
# As written, the way Sophos shows it; both passes normalise it.
|
||||
return c5.SOPHOS_FIREWALL_LABEL, (d.get("os_version") or "").strip()
|
||||
os_slug = (d.get("os") or "").strip()
|
||||
vendor = (d.get("vendor") or "").strip()
|
||||
if os_slug and vendor and vendor.lower() != os_slug.lower():
|
||||
label = f"{vendor} {os_slug}"
|
||||
else:
|
||||
label = os_slug or vendor
|
||||
return (label or None), ((d.get("os_version") or "").strip() or None)
|
||||
|
||||
|
||||
def load_netdisco_config(db: Session) -> Optional[dict]:
|
||||
"""Decrypt + parse netdisco_config, or None when not configured.
|
||||
|
||||
Only the host is required. Netdisco's own demo answers the inventory report
|
||||
with no authentication at all, and an instance behind a reverse proxy that
|
||||
authenticates for it is a real deployment too — refusing to run without a
|
||||
credential would lock both out. Production instances should still set an
|
||||
API key; the settings card says so.
|
||||
"""
|
||||
from app.auth.setting_crypto import read_setting_value
|
||||
raw = read_setting_value(db, SETTING_KEY)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("netdisco_config is not valid JSON")
|
||||
return None
|
||||
return cfg if cfg.get("host") else None
|
||||
|
||||
|
||||
def _build_client(cfg: dict):
|
||||
from app.integrations.netdisco_client import NetdiscoClient, DEFAULT_PORT
|
||||
return NetdiscoClient(
|
||||
host=cfg["host"],
|
||||
api_key=cfg.get("api_key") or "",
|
||||
username=cfg.get("username") or "",
|
||||
password=cfg.get("password") or "",
|
||||
port=int(cfg.get("port") or DEFAULT_PORT),
|
||||
use_https=bool(cfg.get("use_https", False)),
|
||||
verify_ssl=cfg.get("verify_ssl", True),
|
||||
)
|
||||
|
||||
|
||||
# Cross-process guard, same reasoning as the vCenter and IGEL syncs: the
|
||||
# nightly job and a manual trigger run in different contexts and would update
|
||||
# the same asset rows in different orders.
|
||||
_SYNC_ADVISORY_LOCK_KEY = 0x54560104 # "TV" + 04
|
||||
|
||||
|
||||
# A thin client IGEL UMS manages is never a Netdisco device: a name or address
|
||||
# shared with one is another device (field report 2026-09-15).
|
||||
_NOT_NETDISCO = (Asset.igel_unit_id,)
|
||||
|
||||
|
||||
def _other_hardware(a: Asset, serial: Optional[str], name: str) -> bool:
|
||||
"""A row pinned to this address that belongs to a different box.
|
||||
|
||||
The address pin outlives the device it was set for: an AP that moved, a
|
||||
fork from before 053, a switch pulled out of Netdisco. The next device to
|
||||
get that address used to take the row — its name, its status, the old
|
||||
device's findings (field report 2026-09-15, a Windows server still carrying
|
||||
a UniFi AP's address). A device that reports a serial the row does not
|
||||
carry, under a different name, is new hardware. Both are needed: stack
|
||||
members share the name and differ in serial, a renamed switch without a
|
||||
serial differs only in name — both stay on the row.
|
||||
"""
|
||||
return bool(serial) and a.netdisco_serial != serial and different_hosts(name, a.hostname)
|
||||
|
||||
|
||||
def _find_or_create_asset(db: Session, d: dict, auto_create: bool):
|
||||
"""Match a Netdisco device to an asset by serial, then canonical IP, then name.
|
||||
|
||||
The canonical address is what Netdisco itself keys on and it was the only
|
||||
pin here for a reason: it is configured statically on a switch's management
|
||||
interface and survives the renames that a switch does get ("sw-12" →
|
||||
"sw-floor2-a"). But it is only ever a stand-in for the identity. Field
|
||||
report 2026-09-10: an access point updated its firmware, took a new DHCP
|
||||
lease with it, and forked a second asset — same name, same serial — while
|
||||
the reconcile put the old row INACTIVE. The hostname fallback could not
|
||||
catch it either: it is pinned on `netdisco_device_ip`, so the asset holding
|
||||
the OLD address was filtered out of exactly the lookup meant to rescue it.
|
||||
|
||||
The serial goes first because it is the one value that survives both a
|
||||
rename and a re-address. It is pinned once and then left alone: stack
|
||||
members (VSF/IRF) report one name and one address per chassis but a serial
|
||||
EACH, and they are one logical device — the address is what holds them
|
||||
together, so the asset keeps the first serial it saw and the others match
|
||||
on the address as before.
|
||||
"""
|
||||
ip = (d.get("ip") or "").strip()
|
||||
name = (d.get("name") or "").strip()
|
||||
serial = _stable_serial(d.get("serial"))
|
||||
# A device that answers no name is identified by its canonical address —
|
||||
# the WHOLE address. `.split(".")[0]` is the short form of a hostname; on
|
||||
# an address it leaves the first octet, which is both a nonsense asset name
|
||||
# ("192") and one that every other nameless device in that /8 then matches
|
||||
# on, merging unrelated devices onto a single asset.
|
||||
hostname = name or ip
|
||||
short = name.split(".")[0] if name else ""
|
||||
|
||||
def _pin(a):
|
||||
if ip and a.netdisco_device_ip != ip:
|
||||
a.netdisco_device_ip = ip
|
||||
# Never overwritten: on a stack this would flip between chassis on
|
||||
# every row, and on every device it would let a later bad read take
|
||||
# the identity away from a good one.
|
||||
if serial and not a.netdisco_serial:
|
||||
a.netdisco_serial = serial
|
||||
|
||||
if serial:
|
||||
# limit(2): a serial that two assets carry identifies neither. It
|
||||
# should not happen — the pin is set once, from this same check — but
|
||||
# a placeholder that slipped the filter above would show up exactly
|
||||
# like this, and then the address and the name still decide.
|
||||
rows = db.query(Asset).filter(Asset.netdisco_serial == serial).limit(2).all()
|
||||
if len(rows) == 1:
|
||||
_pin(rows[0])
|
||||
return rows[0], "netdisco-serial"
|
||||
if rows:
|
||||
logger.info("Netdisco sync: serial %s wird von mehreren Assets "
|
||||
"gefuehrt — kein Identifikator", serial)
|
||||
|
||||
if ip:
|
||||
a = next((a for a in db.query(Asset).filter(Asset.netdisco_device_ip == ip)
|
||||
.order_by(Asset.id) if not _other_hardware(a, serial, name)), None)
|
||||
if a:
|
||||
_pin(a)
|
||||
# Heal an asset an earlier sync named after the first octet of this
|
||||
# very address: that name is the fingerprint of the bug above, and
|
||||
# nothing else legitimately carries it. A device that now answers a
|
||||
# name is renamed by the caller instead.
|
||||
if not name and a.hostname == ip.split(".")[0]:
|
||||
from app.services.asset_lifecycle import rename
|
||||
rename(db, a, ip[:255], "Netdisco")
|
||||
return a, "netdisco-ip"
|
||||
|
||||
for candidate in [c for c in (hostname, short) if c]:
|
||||
a = match_by_hostname(db, candidate, pin_col=Asset.netdisco_device_ip,
|
||||
pin_value=ip, ip=ip, not_pinned_by=_NOT_NETDISCO)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "hostname"
|
||||
if ip:
|
||||
a = match_by_ip(db, ip, name, not_pinned_by=_NOT_NETDISCO)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "ip"
|
||||
|
||||
if auto_create and (hostname or ip):
|
||||
a = Asset(hostname=(short or hostname or ip)[:255], ip_address=ip[:45] or None,
|
||||
netdisco_device_ip=ip or None, netdisco_serial=serial,
|
||||
source=AssetSource.NETDISCO, status=AssetStatus.ACTIVE)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
logger.info("Netdisco sync: auto-created asset %s", a.hostname)
|
||||
from app.services.asset_lifecycle import created
|
||||
created(db, a, "Netdisco")
|
||||
return a, "created"
|
||||
return None, "skipped"
|
||||
|
||||
|
||||
def run_netdisco_sync(db: Session) -> dict:
|
||||
"""Sync every Netdisco device → assets, then Aruba CVEs."""
|
||||
cfg = load_netdisco_config(db)
|
||||
if not cfg:
|
||||
raise RuntimeError("Netdisco is not configured (settings.netdisco_config missing/incomplete).")
|
||||
# The lock must NOT ride on `db`: this sync commits, and a committed
|
||||
# Session gives its connection back to the pool — taking a session-scoped
|
||||
# lock with it. See database.advisory_lock.
|
||||
from app.database import advisory_lock
|
||||
with advisory_lock(_SYNC_ADVISORY_LOCK_KEY) as got:
|
||||
if not got:
|
||||
logger.warning("Netdisco sync skipped — another Netdisco sync holds the lock")
|
||||
return {"skipped": "another sync already running"}
|
||||
return _run_netdisco_sync_locked(db, cfg)
|
||||
|
||||
|
||||
def _run_netdisco_sync_locked(db: Session, cfg: dict) -> dict:
|
||||
from app.services.asset_lifecycle import reconcile_netdisco_by_seen_ids, rename
|
||||
|
||||
auto_create = bool(cfg.get("auto_create_assets", True))
|
||||
stats = {"devices": 0, "assets_matched": 0, "assets_created": 0,
|
||||
"fw_devices": 0, "no_version": 0, "cve_findings": 0,
|
||||
"assets_inactivated": 0, "assets_reactivated": 0, "errors": []}
|
||||
seen_asset_ids: set = set()
|
||||
# Stack-Mitglieder (VSF/IRF) melden sich je Chassis unter demselben Namen
|
||||
# und derselben Adresse — ein logisches Geraet, eine Firmware, ein CVE-Satz,
|
||||
# also ein Asset. Unterscheidbar sind sie nur an der Seriennummer, und die
|
||||
# ueberschriebe sonst jedes Mitglied beim naechsten: {asset_id: [serials]}.
|
||||
serials: dict = {}
|
||||
|
||||
client = _build_client(cfg)
|
||||
try:
|
||||
devices = client.get_devices()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Netdisco inventory fetch failed: {e}") from e
|
||||
finally:
|
||||
# Closed here and not after the asset loop: everything below is DB
|
||||
# work, and the HTTP session has nothing left to do.
|
||||
client.close()
|
||||
|
||||
for d in devices:
|
||||
stats["devices"] += 1
|
||||
try:
|
||||
asset, how = _find_or_create_asset(db, d, auto_create)
|
||||
if not asset:
|
||||
continue
|
||||
if how == "created":
|
||||
stats["assets_created"] += 1
|
||||
else:
|
||||
stats["assets_matched"] += 1
|
||||
# Matched on a stable identifier — the serial, or the
|
||||
# canonical address — means the name Netdisco reports now is
|
||||
# the current one. Switches get renamed; access points too.
|
||||
name = (d.get("name") or "").strip()
|
||||
if how in ("netdisco-ip", "netdisco-serial") and name \
|
||||
and asset.hostname != name:
|
||||
rename(db, asset, (name.split(".")[0] or name)[:255], "Netdisco")
|
||||
if d.get("ip"):
|
||||
asset.ip_address = d["ip"][:45]
|
||||
os_name, os_version = os_and_version(d)
|
||||
if os_name:
|
||||
asset.operating_system = os_name[:255]
|
||||
if os_version:
|
||||
asset.os_version = os_version[:100]
|
||||
else:
|
||||
# A device Netdisco has discovered but not yet read a version
|
||||
# from (no SNMP credentials for it, or a discovery run that has
|
||||
# not reached it). Counted, because "no findings" must not read
|
||||
# the same as "could not look".
|
||||
stats["no_version"] += 1
|
||||
if scan_key(os_name):
|
||||
stats["fw_devices"] += 1
|
||||
if d.get("location"):
|
||||
asset.location = d["location"][:255]
|
||||
# The raw firmware string lives here: the code-line prefix
|
||||
# ("WC." / "PL.") is what an operator matches against HPE's download
|
||||
# page, and os_version now holds the numeric form the scanners need.
|
||||
serial = (d.get("serial") or "").strip()
|
||||
if serial and asset.id:
|
||||
known = serials.setdefault(asset.id, [])
|
||||
if serial not in known:
|
||||
known.append(serial)
|
||||
members = serials.get(asset.id) or ([serial] if serial else [])
|
||||
desc = " — ".join(x for x in (
|
||||
" ".join(y for y in (d.get("vendor"), d.get("model")) if y),
|
||||
f"firmware {d['os_version']}" if d.get("os_version") else "",
|
||||
("serials " if len(members) > 1 else "serial ") + ", ".join(members)
|
||||
if members else "") if x)
|
||||
asset.description = desc[:1000] or None
|
||||
asset.last_scan = datetime.now()
|
||||
asset.last_seen = datetime.now()
|
||||
asset.last_seen_source = "netdisco"
|
||||
db.flush()
|
||||
if asset.id:
|
||||
seen_asset_ids.add(asset.id)
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"device {d.get('name') or d.get('ip')}: {e}")
|
||||
|
||||
db.commit()
|
||||
|
||||
# CVE pass over the assets this sync touched. Runs after the commit so a
|
||||
# scan failure cannot lose the inventory we just collected.
|
||||
c5.take_sophos_no_row() # this run's CVEs only
|
||||
try:
|
||||
stats["cve_findings"] = _run_cve_scan(db, seen_asset_ids)
|
||||
except Exception as e:
|
||||
stats["errors"].append(f"cve scan: {e}")
|
||||
no_row = c5.take_sophos_no_row()
|
||||
if no_row:
|
||||
stats["errors"].append(no_row)
|
||||
|
||||
try:
|
||||
recon = reconcile_netdisco_by_seen_ids(
|
||||
db, seen_asset_ids=seen_asset_ids,
|
||||
reason="not reported by the latest Netdisco sync")
|
||||
stats["assets_inactivated"] = recon["inactivated"]
|
||||
stats["assets_reactivated"] = recon["reactivated"]
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Netdisco reconcile failed: %s", e)
|
||||
|
||||
logger.info(
|
||||
"Netdisco sync done: %d devices, %d matched, %d created, %d with a "
|
||||
"firmware verdict, %d without a version, %d CVE findings, "
|
||||
"%d inactivated, %d reactivated",
|
||||
stats["devices"], stats["assets_matched"], stats["assets_created"],
|
||||
stats["fw_devices"], stats["no_version"], stats["cve_findings"],
|
||||
stats["assets_inactivated"], stats["assets_reactivated"])
|
||||
return stats
|
||||
|
||||
|
||||
def scan_key(os_name: Optional[str]) -> Optional[str]:
|
||||
"""The curated key for an OS string already written onto an asset, or None.
|
||||
|
||||
The asset-side counterpart to firmware_family(): that one reads a Netdisco
|
||||
row, this one reads the canonical label the row was turned into, which is
|
||||
all a re-scan of an existing asset has.
|
||||
"""
|
||||
return (c5.aruba_key(os_name or "") or c5.cisco_key(os_name or "")
|
||||
or c5.extreme_key(os_name or "") or c5.ubiquiti_key(os_name or "")
|
||||
or c5.netscaler_key(os_name or "") or c5.loadmaster_key(os_name or "")
|
||||
or c5.sophos_key(os_name or "")
|
||||
or ("brocade-fabricos"
|
||||
if _FABOS_LABEL_RE.match((os_name or "").strip()) else None))
|
||||
|
||||
|
||||
def _run_cve_scan(db: Session, asset_ids: set) -> int:
|
||||
"""CVE pass over the assets this sync touched — both sources, every vendor.
|
||||
|
||||
The NVD-CPE scan runs FIRST and the cvelistV5 one second, sharing a
|
||||
`touched` set, for the reason the IGEL sync documents: the cvelistV5 pass
|
||||
closes what it cannot re-confirm, and each vendor has CVEs that exist only
|
||||
on the CPE side — the three 2023 ArubaOS-Switch flaws (HPE states them as
|
||||
prose carrying no machine-readable bound), CVE-2019-12660 (Cisco puts the
|
||||
affected release in the product name) and every EXOS CVE before 2025 (filed
|
||||
by MITRE with vendor "n/a", so cvelistV5 holds no structured data for them
|
||||
at all). Run the other way round and
|
||||
every nightly sync would close them and the next one would reopen them.
|
||||
"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
from app.services import app_cve_scanner_service as cpe
|
||||
index = c5.load_index(db) or {}
|
||||
if not index:
|
||||
logger.info("Netdisco sync: no cvelistV5 index yet — CVE pass deferred "
|
||||
"to the nightly app-CVE scan")
|
||||
new_ids: list = []
|
||||
total = 0
|
||||
for asset in db.query(Asset).filter(Asset.id.in_(asset_ids)).all():
|
||||
key = scan_key(asset.operating_system)
|
||||
if not key:
|
||||
continue # not a family we can decide — see the module docstring
|
||||
touched: set = set()
|
||||
try:
|
||||
total += cpe.scan_asset_os(db, asset, new_ids, touched=touched)
|
||||
except Exception as e:
|
||||
logger.warning("Netdisco CPE scan failed for %s: %s", asset.hostname, e)
|
||||
if not index:
|
||||
continue
|
||||
if key == "brocade-fabricos":
|
||||
# NVD only. Brocade's own records state the bounds as a sentence —
|
||||
# "before 9.2.1c3, and 9.2.2 though 9.2.2b" — so cvelistV5 carries
|
||||
# nothing structured to match against. NVD's analysts turn the same
|
||||
# prose into proper ranges, and the CPE pass above already ran.
|
||||
continue
|
||||
pass_fn = (c5.scan_asset_cisco if key.startswith("cisco-")
|
||||
else c5.scan_asset_extreme if key.startswith("extreme-")
|
||||
else c5.scan_asset_ubiquiti if key.startswith("ubiquiti-")
|
||||
else c5.scan_asset_netscaler if key.startswith("netscaler-")
|
||||
else c5.scan_asset_loadmaster if key.startswith("kemp-")
|
||||
else c5.scan_asset_sophos if key.startswith("sophos-")
|
||||
else c5.scan_asset_aruba)
|
||||
try:
|
||||
total += pass_fn(db, asset, index, new_ids, touched=touched)
|
||||
except Exception as e:
|
||||
logger.warning("Netdisco CNA CVE scan failed for %s: %s", asset.hostname, e)
|
||||
db.commit()
|
||||
if new_ids:
|
||||
# Same tail as the app-CVE and IGEL scans: audit, enrich, notify. A
|
||||
# finding that never reaches EPSS/KEV enrichment or the new-CVE mail is
|
||||
# half a finding.
|
||||
try:
|
||||
from app.services.audit_events import audit_new_vulnerabilities
|
||||
audit_new_vulnerabilities(db, new_ids, source="app-scan")
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.debug("Netdisco detected-audit failed: %s", e)
|
||||
try:
|
||||
from app.models.vulnerability import Vulnerability
|
||||
from app.services.enrichment_service import enrich_vulnerabilities
|
||||
from app.services.email_service import dispatch_new_vuln_notifications
|
||||
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
|
||||
if fresh:
|
||||
enrich_vulnerabilities(db, fresh)
|
||||
dispatch_new_vuln_notifications(db, fresh)
|
||||
except Exception as e:
|
||||
logger.debug("Netdisco enrichment/notify failed: %s", e)
|
||||
return total
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Patch plan — open findings grouped by the product that has to be updated.
|
||||
|
||||
The findings list answers "what is wrong"; this answers "what do I patch
|
||||
first". Each group is one fix action (update product X) with what it removes:
|
||||
hosts, findings, CVEs, summed priority. Ranking is deterministic and uses only
|
||||
data the tool already trusts:
|
||||
|
||||
1. CVEs exploited in the wild (CISA KEV / ENISA EUVD) — any group with one
|
||||
comes first, most exploited CVEs first
|
||||
2. summed priority_score — the risk the update takes off the table
|
||||
|
||||
A finding that names several products (CVE-2026-16417: Chrome AND Edge) counts
|
||||
toward each: either update is work the operator has to do.
|
||||
|
||||
Fixed versions are listed as the scanners reported them, never compared —
|
||||
version order is family-specific and a wrong "latest" would be worse than none.
|
||||
|
||||
The Jev hint (jev_config_dependent) is only listed per group so the operator
|
||||
knows which CVEs to check for a non-default setup; it never changes the rank.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset, AssetStatus
|
||||
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
||||
from app.models.vulnerability_package import VulnerabilityPackage
|
||||
|
||||
# Open work only: pending_verification is already patched, awaiting a rescan.
|
||||
_WORK_STATES = (VulnerabilityStatus.open, VulnerabilityStatus.patch_failed)
|
||||
|
||||
|
||||
def open_work(query):
|
||||
"""Restrict a Vulnerability query to open work on ACTIVE assets.
|
||||
Shared with jev_triage_service so the hint covers exactly the plan's rows."""
|
||||
return (query.join(Asset, Asset.id == Vulnerability.asset_id)
|
||||
.filter(Asset.status == AssetStatus.ACTIVE)
|
||||
.filter(Vulnerability.status.in_(_WORK_STATES)))
|
||||
|
||||
|
||||
# Jev "noul" (its yes/no answer type: probability of yes) above which the
|
||||
# CVE is listed as "check your config".
|
||||
CONFIG_HINT_THRESHOLD = 0.7
|
||||
|
||||
|
||||
def build_patch_plan(db: Session, limit: Optional[int] = None) -> List[dict]:
|
||||
"""The whole plan in rank order; limit truncates it to the top N."""
|
||||
rows = open_work(db.query(
|
||||
Vulnerability.id, Vulnerability.asset_id, Vulnerability.cve_id,
|
||||
Vulnerability.package_name, Vulnerability.fixed_version,
|
||||
Vulnerability.priority_score, Vulnerability.kev_listed,
|
||||
Vulnerability.euvd_listed, Vulnerability.jev_config_dependent,
|
||||
)).all()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Per-product detail, one statement for the whole fleet.
|
||||
packages = defaultdict(list)
|
||||
for vid, name, fixed in open_work(
|
||||
db.query(VulnerabilityPackage.vulnerability_id, VulnerabilityPackage.package_name,
|
||||
VulnerabilityPackage.fixed_version)
|
||||
.join(Vulnerability, Vulnerability.id == VulnerabilityPackage.vulnerability_id)
|
||||
):
|
||||
packages[vid].append((name, fixed))
|
||||
|
||||
groups = {}
|
||||
for r in rows:
|
||||
# Findings without a product (pseudo-CVEs, OS KBs without a package)
|
||||
# become a one-CVE action of their own.
|
||||
for name, fixed in packages.get(r.id) or [(r.package_name or r.cve_id, r.fixed_version)]:
|
||||
key = name.strip().casefold()
|
||||
g = groups.setdefault(key, {
|
||||
"product": name.strip(), "hosts": set(), "findings": 0, "cves": set(),
|
||||
"fixed": set(), "exploited": set(), "config": set(),
|
||||
"priority_sum": 0.0, "priority_max": 0.0,
|
||||
})
|
||||
prio = r.priority_score or 0.0
|
||||
g["hosts"].add(r.asset_id)
|
||||
g["findings"] += 1
|
||||
g["cves"].add(r.cve_id)
|
||||
if fixed:
|
||||
g["fixed"].add(fixed)
|
||||
if r.kev_listed or r.euvd_listed:
|
||||
g["exploited"].add(r.cve_id)
|
||||
if (r.jev_config_dependent or 0) >= CONFIG_HINT_THRESHOLD:
|
||||
g["config"].add(r.cve_id)
|
||||
g["priority_sum"] += prio
|
||||
g["priority_max"] = max(g["priority_max"], prio)
|
||||
|
||||
plan = [{
|
||||
"product": g["product"],
|
||||
"hosts": len(g["hosts"]),
|
||||
"findings": g["findings"],
|
||||
"cve_count": len(g["cves"]),
|
||||
"fixed_versions": sorted(g["fixed"]),
|
||||
"exploited_cves": sorted(g["exploited"]),
|
||||
"config_dependent_cves": sorted(g["config"]),
|
||||
"priority_sum": round(g["priority_sum"], 1),
|
||||
"priority_max": round(g["priority_max"], 1),
|
||||
} for g in groups.values()]
|
||||
plan.sort(key=lambda g: (len(g["exploited_cves"]), g["priority_sum"]), reverse=True)
|
||||
return plan[:limit] if limit is not None else plan
|
||||
@@ -0,0 +1,540 @@
|
||||
"""Sync run ledger + failure alerting.
|
||||
|
||||
Every sync job — Wazuh, Nessus, Intune, vCenter, IGEL, Netdisco; button or
|
||||
scheduler —
|
||||
runs inside `record_sync_run(source, trigger)`. That writes ONE `sync_runs`
|
||||
row: started, finished, status, the stats dict, and the error that stopped it.
|
||||
|
||||
The ledger uses its OWN session. The job's session may be mid-transaction or
|
||||
already rolled back when the job dies, and the record of the failure must not
|
||||
depend on it.
|
||||
|
||||
A failed run sends one mail per source per ALERT_COOLDOWN (24h) — an hourly
|
||||
schedule against a dead API must not send 24 mails — and states the last
|
||||
successful sync, i.e. how long the coverage gap already is.
|
||||
"""
|
||||
import contextlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.sync_run import SyncRun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOURCES = {
|
||||
"wazuh": "Wazuh",
|
||||
"nessus": "Tenable Nessus",
|
||||
"intune": "Microsoft Intune",
|
||||
"vcenter": "VMware vCenter",
|
||||
"igel": "IGEL UMS",
|
||||
"netdisco": "Netdisco",
|
||||
"dockhand": "Dockhand",
|
||||
}
|
||||
# A source counts as configured when its config setting exists and is non-empty.
|
||||
CONFIG_KEYS = {
|
||||
"wazuh": "wazuh_config",
|
||||
"nessus": "nessus_config",
|
||||
"intune": "intune_config",
|
||||
"vcenter": "vcenter_config",
|
||||
"igel": "igel_config",
|
||||
"netdisco": "netdisco_config",
|
||||
"dockhand": "dockhand_config",
|
||||
}
|
||||
# Inventory syncs (Intune/vCenter/IGEL/Netdisco/Dockhand) are nightly: 36h means "missed one
|
||||
# night" without flagging a slow one. Wazuh and Nessus follow their own
|
||||
# ScanSchedule — see stale_thresholds(): twice the interval, at least 36h,
|
||||
# and never stale without an enabled schedule (manual-only sources).
|
||||
STALE_AFTER = timedelta(hours=36)
|
||||
CRON_STALE_AFTER = timedelta(days=8) # a cron expression we do not parse
|
||||
ALERT_COOLDOWN = timedelta(hours=24)
|
||||
SETTING_ENABLED = "sync_alert_enabled"
|
||||
SETTING_RECIPIENTS = "sync_alert_recipients"
|
||||
MAX_ERRORS_KEPT = 50
|
||||
|
||||
_local = threading.local()
|
||||
_alert_lock = threading.Lock()
|
||||
|
||||
|
||||
class SyncRunHandle:
|
||||
"""What the job sees: a stats dict to fill and fail() for a run that
|
||||
ended without an exception but did nothing useful."""
|
||||
|
||||
def __init__(self, run_id: Optional[int], source: str):
|
||||
self.run_id = run_id
|
||||
self.source = source
|
||||
self.stats: dict = {}
|
||||
self.error: Optional[str] = None
|
||||
|
||||
def fail(self, message: str) -> None:
|
||||
self.error = str(message)[:2000]
|
||||
|
||||
|
||||
# An error that means the SOURCE is broken, not one agent: the login was
|
||||
# refused, the API answered 5xx, nothing answered at all. The Wazuh manager
|
||||
# API (55000) and the indexer (9200) are two services — the asset sync logs
|
||||
# in to the first, the vulnerability sync reads the second — so the API can
|
||||
# be dead while 64 agents "sync" fine. That run used to be COMPLETED · 1 ERR.
|
||||
SOURCE_FAILURE_RE = re.compile(
|
||||
r"authentication failed|authenticat\w* (error|refused)|invalid credentials|"
|
||||
r"server error '5\d\d|client error '40[13]|\b50[0-9]\b (internal|bad gateway|service unavailable|gateway)|"
|
||||
r"connection (refused|reset|failed|error)|failed to connect|all connection attempts failed|"
|
||||
r"connecterror|name or service not known|nodename nor servname|network is unreachable|"
|
||||
r"no route to host|ssl|certificate verify|wazuh sync failed|indexer request failed|"
|
||||
r"configuration (missing|not found|incomplete)|config not set",
|
||||
re.I)
|
||||
|
||||
|
||||
def source_failure(errors: Iterable) -> Optional[str]:
|
||||
"""The first error that says the source itself is down, else None."""
|
||||
for e in errors or ():
|
||||
if SOURCE_FAILURE_RE.search(str(e)):
|
||||
return str(e)[:500]
|
||||
return None
|
||||
|
||||
|
||||
def fail_if_source_broken(handle: SyncRunHandle, triggered: int, errors: list,
|
||||
phase_failures: Iterable = ()) -> None:
|
||||
"""Decide whether a run that ended without an exception still failed.
|
||||
|
||||
phase_failures — a whole phase raised (the asset sync could not log in).
|
||||
Always a failure, whatever the other phases did: the inventory was
|
||||
not refreshed and the API is down, and somebody has to know that.
|
||||
every agent failed — authentication refused, API down: failed, not
|
||||
"completed with 64 errors".
|
||||
a source-level error among the agents — the API died mid-run, or one
|
||||
service of the two is down: failed, even with 40 agents through.
|
||||
anything else (an agent Wazuh no longer knows, one timeout) — completed
|
||||
with errors; the health panel shows it amber, no mail.
|
||||
"""
|
||||
phase_failures = [str(p) for p in phase_failures if p]
|
||||
if phase_failures:
|
||||
handle.fail("; ".join(phase_failures))
|
||||
return
|
||||
if triggered == 0 and errors:
|
||||
handle.fail(f"all {len(errors)} agents failed — {errors[0]}")
|
||||
return
|
||||
hit = source_failure(errors)
|
||||
if hit:
|
||||
handle.fail(f"{SOURCES.get(handle.source, handle.source)} API failing mid-run "
|
||||
f"({triggered} agents synced, {len(errors)} errors) — {hit}")
|
||||
|
||||
|
||||
def fail_if_nothing_synced(handle: SyncRunHandle, triggered: int, errors: list) -> None:
|
||||
"""Kept for callers that only know triggered/errors — same rules."""
|
||||
fail_if_source_broken(handle, triggered, errors)
|
||||
|
||||
|
||||
def report_source_failure(source: str, trigger: str, phase: str, error: str) -> None:
|
||||
"""One failed row (and the mail, under the usual cooldown) for a source
|
||||
failure discovered by a job that is not that source's sync — the nightly
|
||||
app scan, SCA refresh or EOL check finding the Wazuh API refusing to log
|
||||
in. Inside a running sync of the same source it marks that run instead."""
|
||||
with record_sync_run(source, trigger) as run:
|
||||
run.stats.setdefault("phase", phase)
|
||||
run.fail(f"{phase}: {error}")
|
||||
|
||||
|
||||
def probe_wazuh_api(client, phase: str, trigger: str = "scheduled") -> Optional[str]:
|
||||
"""One login before a job walks the manager API per asset. When it is
|
||||
refused, that is recorded as a failed Wazuh run — otherwise the job logs
|
||||
a warning per asset and finishes "completed", and the dead API is
|
||||
discovered by whoever presses the button next. A login that works
|
||||
writes nothing: a probe is not a sync and must not count as one.
|
||||
Returns the error text, or None when the API answers."""
|
||||
try:
|
||||
client.login()
|
||||
return None
|
||||
except Exception as e:
|
||||
err = _error_text(e)
|
||||
logger.error("Wazuh API refused the %s login: %s", phase, err)
|
||||
try:
|
||||
report_source_failure("wazuh", trigger, phase, err)
|
||||
except Exception as e2:
|
||||
logger.error("sync_runs: could not record the %s probe failure: %s", phase, e2)
|
||||
return err
|
||||
|
||||
|
||||
def _error_text(e: BaseException) -> str:
|
||||
detail = getattr(e, "detail", None) # HTTPException carries the message there
|
||||
text = str(detail) if detail else (str(e) or type(e).__name__)
|
||||
return text[:2000]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def record_sync_run(source: str, trigger: str = "manual"):
|
||||
"""Record one sync execution. Nested use (the scheduled Wazuh scan calls
|
||||
the same asset-sync function the button calls) yields the OUTER handle so
|
||||
a run is one row, not two, and one mail, not two."""
|
||||
outer = getattr(_local, "active", None)
|
||||
if outer is not None and outer.source == source:
|
||||
yield outer
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
run = SyncRun(source=source, trigger=trigger, status="running",
|
||||
started_at=datetime.now())
|
||||
try:
|
||||
db.add(run)
|
||||
db.commit()
|
||||
except Exception as e: # ledger unavailable: the job still has to run
|
||||
logger.error("sync_runs: could not open run for %s: %s", source, e)
|
||||
db.close()
|
||||
yield SyncRunHandle(None, source)
|
||||
return
|
||||
|
||||
handle = SyncRunHandle(run.id, source)
|
||||
_local.active = handle
|
||||
try:
|
||||
yield handle
|
||||
except BaseException as e:
|
||||
handle.error = handle.error or _error_text(e)
|
||||
raise
|
||||
finally:
|
||||
_local.active = outer
|
||||
try:
|
||||
_finish(db, run, handle)
|
||||
except Exception as e:
|
||||
logger.error("sync_runs: could not close run %s: %s", run.id, e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _finish(db: Session, run: SyncRun, handle: SyncRunHandle) -> None:
|
||||
stats = dict(handle.stats)
|
||||
errs = stats.get("errors")
|
||||
if isinstance(errs, list):
|
||||
stats["error_count"] = len(errs)
|
||||
stats["errors"] = [str(x)[:500] for x in errs[:MAX_ERRORS_KEPT]]
|
||||
run.finished_at = datetime.now()
|
||||
run.status = "failed" if handle.error else "completed"
|
||||
run.error_message = handle.error
|
||||
try:
|
||||
run.stats = json.dumps(stats, default=str)
|
||||
except Exception:
|
||||
run.stats = None
|
||||
db.commit()
|
||||
level = logger.error if handle.error else logger.info
|
||||
level("sync run #%s %s (%s): %s%s", run.id, run.source, run.trigger, run.status,
|
||||
f" — {handle.error}" if handle.error else "")
|
||||
if handle.error:
|
||||
try:
|
||||
notify_sync_failure(db, run)
|
||||
except Exception as e:
|
||||
logger.error("sync failure mail for %s failed: %s", run.source, e)
|
||||
|
||||
|
||||
def mark_interrupted_runs() -> int:
|
||||
"""Close rows left 'running' by a backend restart mid-sync, else the
|
||||
health panel would show a spinner forever. Called from start_scheduler."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.query(SyncRun).filter(SyncRun.status == "running").all()
|
||||
for r in rows:
|
||||
r.status = "failed"
|
||||
r.finished_at = datetime.now()
|
||||
r.error_message = "interrupted: backend restarted while the sync was running"
|
||||
if rows:
|
||||
db.commit()
|
||||
logger.warning("sync_runs: closed %d interrupted run(s)", len(rows))
|
||||
return len(rows)
|
||||
except Exception as e:
|
||||
logger.error("sync_runs: interrupted-run sweep failed: %s", e)
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---- health ---------------------------------------------------------------
|
||||
|
||||
def _setting(db: Session, key: str) -> Optional[str]:
|
||||
from app.models.setting import Setting
|
||||
try:
|
||||
row = db.query(Setting).filter(Setting.key == key).first()
|
||||
except Exception:
|
||||
return None
|
||||
return row.value if row and row.value is not None else None
|
||||
|
||||
|
||||
def is_alert_enabled(db: Session) -> bool:
|
||||
raw = (_setting(db, SETTING_ENABLED) or "").strip().strip('"').lower()
|
||||
return raw not in ("false", "0", "no", "off")
|
||||
|
||||
|
||||
def get_recipients(db: Session) -> List[tuple]:
|
||||
"""(user_id_or_None, email, display_name). Dedicated setting wins, else
|
||||
the shared notification default (configured list, else all admins)."""
|
||||
from app.models.user import User
|
||||
from app.services.email_service import get_default_recipients
|
||||
|
||||
raw = _setting(db, SETTING_RECIPIENTS)
|
||||
if raw and raw.strip():
|
||||
out, seen = [], set()
|
||||
for em in (e.strip() for e in re.split(r"[,;\s]+", raw) if e.strip()):
|
||||
if em in seen:
|
||||
continue
|
||||
seen.add(em)
|
||||
u = db.query(User).filter(User.email == em).first()
|
||||
out.append(((u.id if u else None), em, (u.username if u else em)))
|
||||
if out:
|
||||
return out
|
||||
return get_default_recipients(db)
|
||||
|
||||
|
||||
def configured_sources(db: Session) -> set:
|
||||
return {s for s, key in CONFIG_KEYS.items() if (_setting(db, key) or "").strip()}
|
||||
|
||||
|
||||
def _serialize(run: Optional[SyncRun]) -> Optional[dict]:
|
||||
if run is None:
|
||||
return None
|
||||
try:
|
||||
stats = json.loads(run.stats) if run.stats else {}
|
||||
except Exception:
|
||||
stats = {}
|
||||
return {
|
||||
"id": run.id,
|
||||
"source": run.source,
|
||||
"source_label": SOURCES.get(run.source, run.source),
|
||||
"trigger": run.trigger,
|
||||
"status": run.status,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
"error_message": run.error_message,
|
||||
"error_count": stats.get("error_count", 0) or 0,
|
||||
"stats": stats,
|
||||
"alerted_at": run.alerted_at,
|
||||
}
|
||||
|
||||
|
||||
def stale_thresholds(db: Session) -> Dict[str, Optional[timedelta]]:
|
||||
"""How long without a success before a source counts as stale. None =
|
||||
never (no enabled schedule drives it, so silence is not a failure)."""
|
||||
from app.models.scan_schedule import ScanSchedule, ScheduleInterval
|
||||
interval_of = {
|
||||
ScheduleInterval.EVERY_HOUR: timedelta(hours=1),
|
||||
ScheduleInterval.EVERY_6_HOURS: timedelta(hours=6),
|
||||
ScheduleInterval.EVERY_12_HOURS: timedelta(hours=12),
|
||||
ScheduleInterval.DAILY: timedelta(days=1),
|
||||
ScheduleInterval.WEEKLY: timedelta(weeks=1),
|
||||
}
|
||||
out: Dict[str, Optional[timedelta]] = {s: STALE_AFTER for s in ("intune", "vcenter", "igel")}
|
||||
out.update({"wazuh": None, "nessus": None})
|
||||
try:
|
||||
schedules = db.query(ScanSchedule).filter(ScanSchedule.enabled.is_(True)).all()
|
||||
except Exception:
|
||||
schedules = []
|
||||
for sch in schedules:
|
||||
source = (getattr(sch, "scanner_type", None) or "wazuh").lower()
|
||||
if source not in ("wazuh", "nessus"):
|
||||
continue
|
||||
every = interval_of.get(sch.interval)
|
||||
limit = max(STALE_AFTER, 2 * every) if every else CRON_STALE_AFTER
|
||||
cur = out.get(source)
|
||||
out[source] = limit if cur is None else min(cur, limit)
|
||||
return out
|
||||
|
||||
|
||||
def summarize_health(latest: Dict[str, SyncRun], last_success: Dict[str, SyncRun],
|
||||
configured: Iterable[str],
|
||||
thresholds: Optional[Dict[str, Optional[timedelta]]] = None,
|
||||
now: Optional[datetime] = None) -> List[dict]:
|
||||
"""Pure: one entry per known source with a state the GUI can colour.
|
||||
|
||||
failed — the most recent run of this source failed
|
||||
running — a run is in progress
|
||||
stale — configured, but no successful run within its threshold
|
||||
warning — last run completed, but with per-agent errors
|
||||
ok — last run completed and is recent enough
|
||||
unconfigured — no config stored for this source
|
||||
|
||||
Config beats history: a source whose config was removed reports
|
||||
`unconfigured` even though its old runs are still in the ledger. Tying
|
||||
the state to the runs meant a reset integration stayed red as `stale`
|
||||
forever — nothing left to run, nothing that could ever succeed again.
|
||||
"""
|
||||
now = now or datetime.now()
|
||||
configured = set(configured)
|
||||
thresholds = thresholds if thresholds is not None else {s: STALE_AFTER for s in SOURCES}
|
||||
out = []
|
||||
for source, label in SOURCES.items():
|
||||
last = latest.get(source)
|
||||
last_ser = _serialize(last)
|
||||
good = last_success.get(source)
|
||||
good_at = good.finished_at if good else None
|
||||
if source not in configured:
|
||||
state = "unconfigured"
|
||||
elif last is not None and last.status == "running":
|
||||
state = "running"
|
||||
elif last is not None and last.status == "failed":
|
||||
state = "failed"
|
||||
elif (limit := thresholds.get(source)) is not None and (good_at is None or now - good_at > limit):
|
||||
state = "stale"
|
||||
elif last_ser and last_ser["error_count"] > 0:
|
||||
state = "warning"
|
||||
else:
|
||||
state = "ok"
|
||||
out.append({
|
||||
"source": source,
|
||||
"label": label,
|
||||
"state": state,
|
||||
"configured": source in configured,
|
||||
"last_run": last_ser,
|
||||
"last_success_at": good_at,
|
||||
"error_message": last.error_message if last is not None else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def sync_health(db: Session) -> List[dict]:
|
||||
latest: Dict[str, SyncRun] = {}
|
||||
last_success: Dict[str, SyncRun] = {}
|
||||
for source in SOURCES:
|
||||
latest[source] = (db.query(SyncRun).filter(SyncRun.source == source)
|
||||
.order_by(SyncRun.started_at.desc()).first())
|
||||
last_success[source] = (db.query(SyncRun)
|
||||
.filter(SyncRun.source == source, SyncRun.status == "completed")
|
||||
.order_by(SyncRun.finished_at.desc()).first())
|
||||
return summarize_health(latest, last_success, configured_sources(db), stale_thresholds(db))
|
||||
|
||||
|
||||
def health_payload(db: Session) -> dict:
|
||||
"""What GET /scans/sync-health returns: per-source health plus the
|
||||
failure-mail settings so the panel can edit them in place."""
|
||||
return {
|
||||
"sources": sync_health(db),
|
||||
"alert_enabled": is_alert_enabled(db),
|
||||
"recipients": [e for _, e, _ in get_recipients(db)],
|
||||
"recipients_setting": _setting(db, SETTING_RECIPIENTS) or "",
|
||||
}
|
||||
|
||||
|
||||
def list_runs(db: Session, limit: int = 30) -> List[dict]:
|
||||
rows = db.query(SyncRun).order_by(SyncRun.started_at.desc()).limit(limit).all()
|
||||
return [_serialize(r) for r in rows]
|
||||
|
||||
|
||||
# ---- failure mail ---------------------------------------------------------
|
||||
|
||||
def in_cooldown(last_alerted: Optional[SyncRun], now: Optional[datetime] = None) -> bool:
|
||||
if last_alerted is None or last_alerted.alerted_at is None:
|
||||
return False
|
||||
return (now or datetime.now()) - last_alerted.alerted_at < ALERT_COOLDOWN
|
||||
|
||||
|
||||
def _fmt(dt: Optional[datetime]) -> str:
|
||||
return dt.strftime("%Y-%m-%d %H:%M") if dt else "never"
|
||||
|
||||
|
||||
def _stats_rows(run: SyncRun) -> str:
|
||||
"""Phase and counters of the failed run, so the mail says what still
|
||||
went through (e.g. 64 agents over the indexer while the API was dead)."""
|
||||
ser = _serialize(run) or {}
|
||||
stats = ser.get("stats") or {}
|
||||
rows = []
|
||||
if stats.get("phase"):
|
||||
rows.append(f"<tr><td><b>Phase</b></td><td>{html.escape(str(stats['phase']))}</td></tr>")
|
||||
counters = " ".join(f"{k}={v}" for k, v in stats.items()
|
||||
if k not in ("errors", "phase") and isinstance(v, (int, float, str)))
|
||||
if counters:
|
||||
rows.append(f"<tr><td><b>Run stats</b></td><td><code>{html.escape(counters)}</code></td></tr>")
|
||||
errs = stats.get("errors") or []
|
||||
if errs:
|
||||
shown = "<br>".join(html.escape(str(e)) for e in errs[:10])
|
||||
more = f"<br>… {len(errs) - 10} more" if len(errs) > 10 else ""
|
||||
rows.append(f"<tr><td><b>Errors</b></td><td><code>{shown}{more}</code></td></tr>")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def notify_sync_failure(db: Session, run: SyncRun) -> dict:
|
||||
"""Mail the failure to the sync-alert recipients, once per source per
|
||||
cooldown window, and log it in notification_logs."""
|
||||
from app.models.notification_log import (NotificationLog, NotificationStatus,
|
||||
NotificationType)
|
||||
from app.services.email_service import send_email
|
||||
|
||||
stats = {"sent": 0, "failed": 0, "skipped": None}
|
||||
if not is_alert_enabled(db):
|
||||
stats["skipped"] = "disabled"
|
||||
return stats
|
||||
with _alert_lock:
|
||||
return _notify_locked(db, run, stats)
|
||||
|
||||
|
||||
def _notify_locked(db: Session, run: SyncRun, stats: dict) -> dict:
|
||||
from app.models.notification_log import (NotificationLog, NotificationStatus,
|
||||
NotificationType)
|
||||
from app.services.email_service import send_email
|
||||
|
||||
last_alerted = (db.query(SyncRun)
|
||||
.filter(SyncRun.source == run.source, SyncRun.alerted_at.isnot(None))
|
||||
.order_by(SyncRun.alerted_at.desc()).first())
|
||||
if in_cooldown(last_alerted):
|
||||
stats["skipped"] = "cooldown"
|
||||
logger.info("sync failure mail for %s suppressed (cooldown until %s)",
|
||||
run.source, last_alerted.alerted_at + ALERT_COOLDOWN)
|
||||
return stats
|
||||
|
||||
recipients = get_recipients(db)
|
||||
if not recipients:
|
||||
stats["skipped"] = "no recipients"
|
||||
return stats
|
||||
|
||||
good = (db.query(SyncRun)
|
||||
.filter(SyncRun.source == run.source, SyncRun.status == "completed")
|
||||
.order_by(SyncRun.finished_at.desc()).first())
|
||||
label = SOURCES.get(run.source, run.source)
|
||||
base = os.getenv("DASHBOARD_URL", "http://localhost:3000").rstrip("/")
|
||||
subject = f"[TRUEVULN] Sync failed: {label} ({run.trigger}) — vulnerability coverage at risk"
|
||||
gap = ("No successful sync of this source has ever been recorded."
|
||||
if not (good and good.finished_at) else
|
||||
f"Last successful sync: {_fmt(good.finished_at)} "
|
||||
f"({(datetime.now() - good.finished_at).days} day(s) ago).")
|
||||
body = f"""<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;color:#111">
|
||||
<h2 style="color:#b91c1c">Sync failed: {html.escape(label)}</h2>
|
||||
<table cellpadding="6" style="border-collapse:collapse">
|
||||
<tr><td><b>Source</b></td><td>{html.escape(label)}</td></tr>
|
||||
<tr><td><b>Trigger</b></td><td>{html.escape(run.trigger)}</td></tr>
|
||||
<tr><td><b>Started</b></td><td>{_fmt(run.started_at)}</td></tr>
|
||||
<tr><td><b>Failed</b></td><td>{_fmt(run.finished_at)}</td></tr>
|
||||
<tr><td><b>Error</b></td><td><code>{html.escape(run.error_message or "unknown")}</code></td></tr>
|
||||
{_stats_rows(run)}</table>
|
||||
<p>{html.escape(gap)}</p>
|
||||
<p><b>Until the next successful sync, new vulnerabilities and inventory changes
|
||||
from this source are not detected.</b> Check the connection and credentials in
|
||||
Settings, then run the sync again from Scan Jobs.</p>
|
||||
<p><a href="{html.escape(base)}/scans">Open Scan Jobs</a></p>
|
||||
<p style="color:#6b7280;font-size:12px">You receive at most one mail per source
|
||||
per 24 hours while it keeps failing.</p>
|
||||
</body></html>"""
|
||||
|
||||
for user_id, email, _name in recipients:
|
||||
ok, err = send_email(db, email, subject, body)
|
||||
db.add(NotificationLog(
|
||||
user_id=user_id,
|
||||
notification_type=NotificationType.SYNC_FAILURE,
|
||||
sent_at=datetime.now(),
|
||||
subject=subject[:500],
|
||||
recipient_email=email,
|
||||
status=NotificationStatus.SENT if ok else NotificationStatus.FAILED,
|
||||
message_body=f"{label} sync ({run.trigger}) failed: {run.error_message}",
|
||||
error_message=None if ok else err,
|
||||
))
|
||||
stats["sent" if ok else "failed"] += 1
|
||||
|
||||
# Only a mail somebody actually got starts the cooldown — a broken SMTP
|
||||
# must not silence the alert for a day.
|
||||
if stats["sent"]:
|
||||
run.alerted_at = datetime.now()
|
||||
db.commit()
|
||||
logger.info("sync failure mail for %s: %d sent, %d failed",
|
||||
run.source, stats["sent"], stats["failed"])
|
||||
return stats
|
||||
@@ -262,6 +262,9 @@ def _parse_bulletin(slug: str, html_text: str) -> Optional[dict]:
|
||||
|
||||
return {"id": slug.upper(), "url": _bulletin_url(slug),
|
||||
"cves": cves, "cvss": cvss, "sev": sev,
|
||||
# TeamViewer's own rating. `sev` prefers the CVSS band next to the
|
||||
# score, so the Priority is kept separately for the detail badge.
|
||||
"vendor_sev": (meta.get("Priority") or [""])[0].strip() or None,
|
||||
"desc": (desc or "")[:2000] or None,
|
||||
"affected": affected}
|
||||
|
||||
@@ -408,6 +411,7 @@ def scan_asset(db: Session, asset, packages: list, new_ids: Optional[list] = Non
|
||||
for ident in (entry["cves"] or [entry["id"]]):
|
||||
c = {"cve": ident, "cvss": entry.get("cvss"),
|
||||
"severity": entry.get("sev"),
|
||||
"vendor_sev": ("TeamViewer", entry.get("vendor_sev")),
|
||||
"fixed": row.get("lt"),
|
||||
"desc": entry.get("desc"),
|
||||
"refs": json.dumps([entry["url"]])}
|
||||
|
||||
@@ -24,6 +24,7 @@ from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.asset import Asset, AssetSource, AssetStatus
|
||||
from app.services.asset_matching import match_by_hostname, match_by_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -113,12 +114,13 @@ def _find_or_create_asset(db: Session, *, uuid: Optional[str], hostname: str,
|
||||
return a, "uuid"
|
||||
|
||||
for candidate in [c for c in (hostname, short) if c]:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
|
||||
a = match_by_hostname(db, candidate, pin_col=Asset.vmware_uuid, pin_value=uuid, ip=ip)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "hostname"
|
||||
if short:
|
||||
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
|
||||
a = match_by_hostname(db, f"{short}.%", pin_col=Asset.vmware_uuid,
|
||||
pin_value=uuid, ip=ip)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "hostname-fqdn-prefix"
|
||||
@@ -126,7 +128,7 @@ def _find_or_create_asset(db: Session, *, uuid: Optional[str], hostname: str,
|
||||
# Nessus scan of the same box records the IP too — matching on it keeps
|
||||
# the two sources on one asset instead of creating a duplicate.
|
||||
if ip:
|
||||
a = db.query(Asset).filter(Asset.ip_address == ip).first()
|
||||
a = match_by_ip(db, ip, hostname)
|
||||
if a:
|
||||
_pin(a)
|
||||
return a, "ip"
|
||||
@@ -138,6 +140,8 @@ def _find_or_create_asset(db: Session, *, uuid: Optional[str], hostname: str,
|
||||
db.add(a)
|
||||
db.flush()
|
||||
logger.info("vCenter sync: auto-created asset %s", a.hostname)
|
||||
from app.services.asset_lifecycle import created
|
||||
created(db, a, "vCenter")
|
||||
return a, "created"
|
||||
return None, "skipped"
|
||||
|
||||
@@ -167,7 +171,7 @@ def run_vcenter_sync(db: Session, *, refresh_catalog: bool = False) -> dict:
|
||||
|
||||
|
||||
def _run_vcenter_sync_locked(db: Session, cfg: dict, *, refresh_catalog: bool) -> dict:
|
||||
from app.services.asset_lifecycle import reconcile_vcenter_by_seen_ids
|
||||
from app.services.asset_lifecycle import reconcile_vcenter_by_seen_ids, rename
|
||||
|
||||
auto_create = bool(cfg.get("auto_create_assets", True))
|
||||
sync_hosts = bool(cfg.get("sync_hosts", True))
|
||||
@@ -237,7 +241,7 @@ def _run_vcenter_sync_locked(db: Session, cfg: dict, *, refresh_catalog: bool) -
|
||||
# the name vCenter reports now is the current one.
|
||||
name = (h.get("name") or "").strip()
|
||||
if how == "uuid" and name and asset.hostname != name:
|
||||
asset.hostname = name.split(".")[0] or name
|
||||
rename(db, asset, name.split(".")[0] or name, "vCenter")
|
||||
if h.get("ip_address"):
|
||||
asset.ip_address = h["ip_address"][:45]
|
||||
asset.operating_system = ESXI_OS
|
||||
@@ -245,6 +249,11 @@ def _run_vcenter_sync_locked(db: Session, cfg: dict, *, refresh_catalog: bool) -
|
||||
asset.os_version = str(h["version"])[:100]
|
||||
if h.get("build"):
|
||||
asset.vmware_build = str(h["build"])[:32]
|
||||
# Display, not a match key: the host is already found by its UUID,
|
||||
# and the serial is what an operator reads off the chassis or
|
||||
# quotes to the vendor. Kept when a later sync reports none.
|
||||
if h.get("serial"):
|
||||
asset.vmware_serial = h["serial"]
|
||||
model = " ".join(x for x in (h.get("vendor"), h.get("model")) if x)
|
||||
asset.description = (h.get("full_name") or "") + (f" — {model}" if model else "") or None
|
||||
asset.last_scan = datetime.now()
|
||||
|
||||
@@ -300,6 +300,14 @@ def apply_canonical_from_siblings(db, vuln) -> bool:
|
||||
if cur is None or (f == "cvss_score" and cur == 10.0 and src_val != 10.0):
|
||||
setattr(vuln, f, src_val)
|
||||
changed = True
|
||||
# Vendor ratings are CVE-level too, but merged per vendor from EVERY
|
||||
# sibling rather than copied from one: a new finding for an older CVE
|
||||
# otherwise waited for the Sunday 18-month MSRC ingest (the nightly one
|
||||
# reads 2 months), and a sibling without a rating must not hide another's.
|
||||
for sib in siblings:
|
||||
for vendor, rating in sib.vendor_severities.items():
|
||||
if vendor not in vuln.vendor_severities:
|
||||
changed |= vuln.set_vendor_severity(vendor, rating)
|
||||
return changed
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import api from '../../../lib/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Pagination from '../../../components/shared/Pagination';
|
||||
import {
|
||||
ClockIcon,
|
||||
UserIcon,
|
||||
@@ -43,7 +44,6 @@ function affectedHost(log: AuditLog): string | null {
|
||||
|
||||
type SortKey = 'timestamp' | 'user_id' | 'event_type' | 'resource_type' | 'ip_address';
|
||||
// Same steps as the Vulnerabilities and Assets tables. 1000 is the API cap.
|
||||
const PAGE_SIZES = [50, 100, 250, 500, 1000];
|
||||
|
||||
// column header → sort key (null = not sortable)
|
||||
const COLUMNS: { label: string; key: SortKey | null }[] = [
|
||||
@@ -114,9 +114,6 @@ export default function AuditLogsPage() {
|
||||
|
||||
const formatDate = (dateStr: string) => new Date(dateStr).toLocaleString();
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const from = total === 0 ? 0 : page * pageSize + 1;
|
||||
const to = Math.min((page + 1) * pageSize, total);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -218,35 +215,10 @@ export default function AuditLogsPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination bar */}
|
||||
<div className="border-t border-gray-200 px-4 py-3 flex flex-wrap items-center justify-between gap-3 bg-gray-50 text-sm font-mono text-gray-600">
|
||||
<div>
|
||||
{total === 0 ? '0' : `${from.toLocaleString()}–${to.toLocaleString()}`} of {total.toLocaleString()}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">Per page</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(0); }}
|
||||
className="rounded-md border-gray-300 text-sm py-1 pl-2 pr-7 focus:border-truevuln-blue focus:ring-truevuln-blue"
|
||||
>
|
||||
{PAGE_SIZES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPage(0)} disabled={page === 0}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">«</button>
|
||||
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">‹ Prev</button>
|
||||
<span className="px-2">Page {page + 1} / {totalPages.toLocaleString()}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page + 1 >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">Next ›</button>
|
||||
<button onClick={() => setPage(totalPages - 1)} disabled={page + 1 >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page + 1} pageSize={pageSize} total={total}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
onPageSizeChange={(n) => { setPageSize(n); setPage(0); }}
|
||||
className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+272
-164
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
import { PageLoader } from "@/components/ui/Loading";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import { Cog6ToothIcon, KeyIcon, UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
import api from "../../../lib/api";
|
||||
|
||||
type ProviderName = "local" | "ldap" | "oidc" | "saml";
|
||||
@@ -32,6 +36,15 @@ const PROVIDER_LABELS: Record<ProviderName, string> = {
|
||||
|
||||
const ROLE_PRESETS = ["admin", "editor", "readonly"] as const;
|
||||
|
||||
// Same shape as the settings page: one section at a time in a left rail,
|
||||
// instead of three stacked blocks you have to scroll past to reach the
|
||||
// mapping editor.
|
||||
const SECTIONS = [
|
||||
{ key: "overview", label: "Overview", icon: Cog6ToothIcon },
|
||||
{ key: "providers", label: "Providers", icon: KeyIcon },
|
||||
{ key: "mapping", label: "Role mapping", icon: UserGroupIcon },
|
||||
];
|
||||
|
||||
export default function AuthAdminPage() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [mappings, setMappings] = useState<Record<string, Rule[]>>({});
|
||||
@@ -40,6 +53,8 @@ export default function AuthAdminPage() {
|
||||
const [testResults, setTestResults] = useState<Record<string, any>>({});
|
||||
const [testing, setTesting] = useState<string>("");
|
||||
const [ldapSampleUser, setLdapSampleUser] = useState("");
|
||||
const [section, setSection] = useState("overview");
|
||||
const router = useRouter();
|
||||
|
||||
const load = async () => {
|
||||
const [s, m] = await Promise.all([
|
||||
@@ -57,8 +72,9 @@ export default function AuthAdminPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => {
|
||||
/* permission likely; render fallback */
|
||||
load().catch((e) => {
|
||||
// Admin-only; the sidebar hides it, the URL does not.
|
||||
if (e?.response?.status === 403) router.push('/');
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -135,179 +151,271 @@ export default function AuthAdminPage() {
|
||||
|
||||
const providers = status?.providers || [];
|
||||
|
||||
// One badge per provider instead of two. "Disabled + Incomplete" in orange
|
||||
// read like an error on every provider nobody ever set up — the only real
|
||||
// misconfiguration is a provider that is switched ON but not configured.
|
||||
const providerState = (p: ProviderStatus) =>
|
||||
p.enabled
|
||||
? (p.configured
|
||||
? { label: "Active", cls: "tv-tag tv-tag--ok" }
|
||||
: { label: "Incomplete", cls: "tv-tag tv-tag--warn" })
|
||||
: (p.configured
|
||||
? { label: "Disabled", cls: "tv-tag" }
|
||||
: { label: "Not set", cls: "tv-tag" });
|
||||
|
||||
const selectSection = (key: string) => {
|
||||
setSection(key);
|
||||
window.history.replaceState(null, "", `#${key}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-gray-900 font-mono">Authentication Providers</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Read-only provider configuration is loaded from environment variables (restart required to apply).
|
||||
Role mappings below are editable at runtime.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Global settings */}
|
||||
{status && (
|
||||
<section className="bg-white border border-gray-200 rounded-md p-4 shadow-sm">
|
||||
<h2 className="text-sm font-bold text-gray-700 font-mono uppercase tracking-wider mb-3">Global</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm font-mono">
|
||||
<div><span className="text-gray-500">Lookup order:</span> <span className="font-bold">{status.lookup_order.join(" → ")}</span></div>
|
||||
<div><span className="text-gray-500">JIT provisioning:</span> <span className="font-bold">{status.jit_provisioning ? "ON" : "OFF"}</span></div>
|
||||
<div><span className="text-gray-500">Default JIT role:</span> <span className="font-bold">{status.default_role}</span></div>
|
||||
<div>
|
||||
<span className="text-gray-500">Crypto key (Fernet):</span>{" "}
|
||||
<span className={`font-bold ${status.crypto_key_set ? "text-green-600" : "text-red-600"}`}>
|
||||
{status.crypto_key_set ? "set" : "MISSING — set AUTH_PROVIDER_CRYPTO_KEY"}
|
||||
</span>
|
||||
<div className="max-w-7xl">
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
<aside className="lg:w-56 lg:shrink-0">
|
||||
<div className="lg:sticky lg:top-6 bg-white border border-gray-200 shadow-sm rounded-sm p-4">
|
||||
<div className="flex items-center gap-2 px-2 pb-3 mb-3 border-b border-gray-200">
|
||||
<span className="h-2.5 w-2.5 bg-truevuln-blue" aria-hidden="true" />
|
||||
<span className="font-mono text-sm font-bold tracking-widest text-gray-900">AUTH</span>
|
||||
</div>
|
||||
<nav className="flex lg:flex-col gap-1 overflow-x-auto">
|
||||
{SECTIONS.map((s) => {
|
||||
const isActive = s.key === section;
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
onClick={() => selectSection(s.key)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={clsx(
|
||||
"group flex items-center gap-2.5 whitespace-nowrap border-l-2 px-3 py-2 text-left font-mono text-xs uppercase tracking-widest transition-colors",
|
||||
isActive
|
||||
? "border-truevuln-blue bg-blue-50 font-bold text-truevuln-blue"
|
||||
: "border-transparent text-gray-500 hover:bg-gray-50 hover:text-gray-900"
|
||||
)}
|
||||
>
|
||||
<s.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Provider status cards */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{providers.map((p) => (
|
||||
<div key={p.name} className="bg-white border border-gray-200 rounded-md p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold font-mono">{PROVIDER_LABELS[p.name]}</h3>
|
||||
<div className="flex gap-1">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded font-mono font-bold uppercase ${p.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"}`}>
|
||||
{p.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded font-mono font-bold uppercase ${p.configured ? "bg-blue-100 text-blue-700" : "bg-orange-100 text-orange-700"}`}>
|
||||
{p.configured ? "Configured" : "Incomplete"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 max-w-4xl">
|
||||
{section === "overview" && (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 font-mono mb-4 border-b border-gray-100 pb-2">
|
||||
Authentication Providers
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Provider configuration comes from environment variables and is read-only here
|
||||
(a restart applies changes). The role mappings are editable at runtime.
|
||||
</p>
|
||||
{status && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-10 gap-y-1 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-gray-100">
|
||||
<dt className="text-gray-500">Lookup order</dt>
|
||||
<dd className="font-mono font-semibold text-gray-900">{status.lookup_order.join(" → ")}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-gray-100">
|
||||
<dt className="text-gray-500">JIT provisioning</dt>
|
||||
<dd className="font-mono font-semibold text-gray-900">{status.jit_provisioning ? "on" : "off"}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-gray-100">
|
||||
<dt className="text-gray-500">Default JIT role</dt>
|
||||
<dd className="font-mono font-semibold text-gray-900">{status.default_role}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-gray-100">
|
||||
<dt className="text-gray-500">Crypto key (Fernet)</dt>
|
||||
<dd>
|
||||
{status.crypto_key_set ? (
|
||||
<span className="font-mono font-semibold text-gray-900">set</span>
|
||||
) : (
|
||||
<span className="tv-tag tv-tag--danger">missing</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
{status && !status.crypto_key_set && (
|
||||
<p className="mt-4 text-xs font-mono text-gray-500">
|
||||
Provider secrets cannot be stored until <code>AUTH_PROVIDER_CRYPTO_KEY</code> is set.
|
||||
</p>
|
||||
)}
|
||||
{!status && <PageLoader label="Loading providers…" />}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500 font-mono">{p.user_count} user(s) linked</div>
|
||||
)}
|
||||
|
||||
{/* Test button (skip local) */}
|
||||
{p.name !== "local" && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{p.name === "ldap" && (
|
||||
<input
|
||||
type="text"
|
||||
value={ldapSampleUser}
|
||||
onChange={(e) => setLdapSampleUser(e.target.value)}
|
||||
placeholder="Optional: sample username to search"
|
||||
className="w-full text-xs font-mono border border-gray-300 rounded px-2 py-1"
|
||||
/>
|
||||
)}
|
||||
{section === "providers" && (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 font-mono mb-4 border-b border-gray-100 pb-2">
|
||||
Providers
|
||||
</h3>
|
||||
{!status ? (
|
||||
<PageLoader label="Loading providers…" />
|
||||
) : (
|
||||
<ul role="list" className="divide-y divide-gray-100">
|
||||
{providers.map((p) => {
|
||||
const state = providerState(p);
|
||||
const res = testResults[p.name];
|
||||
return (
|
||||
<li key={p.name} className="py-4 first:pt-0">
|
||||
<div className="flex items-center justify-between gap-x-6">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<p className="text-sm font-semibold leading-6 text-gray-900 font-mono">
|
||||
{PROVIDER_LABELS[p.name]}
|
||||
</p>
|
||||
<span className={state.cls}>{state.label}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-gray-500 font-mono">
|
||||
{p.user_count} user{p.user_count === 1 ? "" : "s"} linked
|
||||
</p>
|
||||
</div>
|
||||
{p.name !== "local" && (
|
||||
<div className="flex flex-none items-center gap-x-2">
|
||||
{p.name === "ldap" && (
|
||||
<input
|
||||
type="text"
|
||||
value={ldapSampleUser}
|
||||
onChange={(e) => setLdapSampleUser(e.target.value)}
|
||||
placeholder="Sample username (optional)"
|
||||
className="w-56 text-xs font-mono border border-gray-300 rounded-md px-2 py-1.5"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => runTest(p.name as any)}
|
||||
disabled={!p.configured || testing === p.name}
|
||||
className="tv-btn"
|
||||
title={p.configured ? "Run a live connection test" : "Configure the provider first"}
|
||||
>
|
||||
{testing === p.name ? "Testing…" : "Test connection"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res && (
|
||||
<div className="mt-3">
|
||||
<span className={res.ok ? "tv-tag tv-tag--ok" : "tv-tag tv-tag--danger"}>
|
||||
{res.ok ? "Connection ok" : "Connection failed"}
|
||||
</span>
|
||||
<pre className="mt-2 text-[11px] font-mono bg-gray-50 border border-gray-200 rounded-md p-3 max-h-60 overflow-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(res, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "mapping" && (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mb-6">
|
||||
<div className="flex items-start justify-between gap-6 mb-4 border-b border-gray-100 pb-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 font-mono">Group → Role mapping</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 max-w-2xl">
|
||||
Patterns use fnmatch-style wildcards (<code>*</code>, <code>?</code>) and are matched
|
||||
case-insensitively against each raw group from the IdP. First matching rule wins.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => runTest(p.name as any)}
|
||||
disabled={!p.configured || testing === p.name}
|
||||
className="w-full text-xs font-mono bg-truevuln-blue text-white rounded px-2 py-1 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={saveMappings}
|
||||
disabled={saving}
|
||||
className="tv-btn tv-btn--primary shrink-0 text-sm px-4 py-2"
|
||||
>
|
||||
{testing === p.name ? "Testing…" : `Test ${p.name.toUpperCase()} connection`}
|
||||
</button>
|
||||
{testResults[p.name] && (
|
||||
<pre className="text-[10px] bg-gray-50 border border-gray-200 rounded p-2 max-h-60 overflow-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(testResults[p.name], null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Role mappings editor */}
|
||||
<section className="bg-white border border-gray-200 rounded-md p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-gray-700 font-mono uppercase tracking-wider">Group → Role mapping</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Patterns use fnmatch-style wildcards (<code>*</code>, <code>?</code>) and are matched case-insensitively
|
||||
against each raw group from the IdP. First matching rule wins.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveMappings}
|
||||
disabled={saving}
|
||||
className="text-sm font-mono bg-truevuln-blue text-white rounded px-4 py-2 hover:bg-blue-600 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving…" : "Save mappings"}
|
||||
</button>
|
||||
</div>
|
||||
{saveMsg && (
|
||||
<div className="mb-3 text-xs font-mono bg-gray-50 border border-gray-200 rounded p-2">
|
||||
{saveMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(["ldap", "oidc", "saml"] as const).map((provider) => (
|
||||
<div key={provider} className="mb-6">
|
||||
<h3 className="text-xs font-bold font-mono uppercase tracking-wider text-gray-600 mb-2">
|
||||
{PROVIDER_LABELS[provider]}
|
||||
</h3>
|
||||
<div className="border border-gray-200 rounded">
|
||||
<table className="min-w-full text-sm font-mono">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500 w-12">#</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500">Group pattern</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500 w-32">→ Role</th>
|
||||
<th className="px-3 py-2 w-32"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(mappings[provider] || []).map((rule, idx) => (
|
||||
<tr key={idx} className="border-t border-gray-100">
|
||||
<td className="px-3 py-2 text-xs text-gray-400">{idx + 1}</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={rule.pattern}
|
||||
onChange={(e) => updateRule(provider, idx, { pattern: e.target.value })}
|
||||
placeholder={
|
||||
provider === "ldap"
|
||||
? "CN=TrueVuln-Admins,*"
|
||||
: provider === "oidc"
|
||||
? "truevuln-admins | <azure-group-uuid>"
|
||||
: "TrueVuln-Admins"
|
||||
}
|
||||
className="w-full text-xs border border-gray-300 rounded px-2 py-1"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
value={rule.role}
|
||||
onChange={(e) => updateRule(provider, idx, { role: e.target.value as any })}
|
||||
className="w-full text-xs border border-gray-300 rounded px-2 py-1"
|
||||
>
|
||||
{ROLE_PRESETS.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<button onClick={() => moveRule(provider, idx, -1)} disabled={idx === 0}
|
||||
className="px-1 text-gray-400 hover:text-truevuln-blue disabled:opacity-30">↑</button>
|
||||
<button onClick={() => moveRule(provider, idx, +1)} disabled={idx === (mappings[provider]?.length || 0) - 1}
|
||||
className="px-1 text-gray-400 hover:text-truevuln-blue disabled:opacity-30">↓</button>
|
||||
<button onClick={() => removeRule(provider, idx)}
|
||||
className="ml-2 px-2 py-0.5 text-[10px] bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100">remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(mappings[provider] || []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-3 text-center text-xs text-gray-400 italic">
|
||||
No rules — users from {provider.toUpperCase()} get the default JIT role ({status?.default_role}).
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="border-t border-gray-100 p-2 bg-gray-50">
|
||||
<button onClick={() => addRule(provider)}
|
||||
className="text-xs font-mono px-3 py-1 bg-white border border-gray-300 rounded hover:bg-gray-100">
|
||||
+ Add rule
|
||||
{saving ? "Saving…" : "Save mappings"}
|
||||
</button>
|
||||
</div>
|
||||
{saveMsg && (
|
||||
<div className={`mb-4 w-full ${saveMsg.startsWith("Save failed") ? "tv-tag tv-tag--danger" : "tv-tag tv-tag--ok"} font-normal px-3 py-2 text-xs`}>
|
||||
{saveMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(["ldap", "oidc", "saml"] as const).map((provider) => (
|
||||
<div key={provider} className="mb-6 last:mb-0">
|
||||
<h4 className="text-xs font-bold font-mono uppercase tracking-wider text-gray-500 mb-2">
|
||||
{PROVIDER_LABELS[provider]}
|
||||
</h4>
|
||||
<div className="border border-gray-200 rounded-md overflow-hidden">
|
||||
<table className="min-w-full text-sm font-mono">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500 w-12">#</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500">Group pattern</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500 w-32">→ Role</th>
|
||||
<th className="px-3 py-2 w-40"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(mappings[provider] || []).map((rule, idx) => (
|
||||
<tr key={idx} className="border-t border-gray-100">
|
||||
<td className="px-3 py-2 text-xs text-gray-400">{idx + 1}</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={rule.pattern}
|
||||
onChange={(e) => updateRule(provider, idx, { pattern: e.target.value })}
|
||||
placeholder={
|
||||
provider === "ldap"
|
||||
? "CN=TrueVuln-Admins,*"
|
||||
: provider === "oidc"
|
||||
? "truevuln-admins | <azure-group-uuid>"
|
||||
: "TrueVuln-Admins"
|
||||
}
|
||||
className="w-full text-xs border border-gray-300 rounded-md px-2 py-1.5"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
value={rule.role}
|
||||
onChange={(e) => updateRule(provider, idx, { role: e.target.value as any })}
|
||||
className="w-full text-xs border border-gray-300 rounded-md px-2 py-1.5"
|
||||
>
|
||||
{ROLE_PRESETS.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<button onClick={() => moveRule(provider, idx, -1)} disabled={idx === 0}
|
||||
className="px-1.5 text-gray-400 hover:text-truevuln-blue disabled:opacity-30"
|
||||
title="Move up" aria-label="Move rule up">↑</button>
|
||||
<button onClick={() => moveRule(provider, idx, +1)} disabled={idx === (mappings[provider]?.length || 0) - 1}
|
||||
className="px-1.5 text-gray-400 hover:text-truevuln-blue disabled:opacity-30"
|
||||
title="Move down" aria-label="Move rule down">↓</button>
|
||||
<button onClick={() => removeRule(provider, idx)}
|
||||
className="tv-btn ml-2 px-2 py-1">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(mappings[provider] || []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-4 text-center text-xs text-gray-500">
|
||||
No rules — users from {provider.toUpperCase()} get the default role
|
||||
({status?.default_role}).
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="border-t border-gray-100 p-2 bg-gray-50">
|
||||
<button onClick={() => addRule(provider)} className="tv-btn">
|
||||
Add rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
import { PageLoader } from "@/components/ui/Loading";
|
||||
|
||||
import { dialog } from "@/components/ui/dialog";
|
||||
|
||||
// Advisory Feeds — configurable RSS sources (ZDI / CERT-EU / BSI / Cisco /
|
||||
// custom). Not matched against the inventory and never alerted on: reading
|
||||
// material that often publishes ahead of NVD/cvelistV5. Served from a cache
|
||||
// the scheduler refreshes every 6h; opening or expanding a feed fetches nothing.
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../../lib/api';
|
||||
|
||||
type FeedItem = { title: string; link: string; date: string; summary: string };
|
||||
type Feed = { id: string; name: string; url: string; enabled: boolean; items: FeedItem[]; error: string | null };
|
||||
type FeedCfg = { id: string; name: string; url: string; enabled: boolean };
|
||||
|
||||
export default function AdvisoryFeedsPage() {
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [userRole, setUserRole] = useState('');
|
||||
const [openFeed, setOpenFeed] = useState<string | null>(null);
|
||||
// Admin config editor
|
||||
const [cfg, setCfg] = useState<FeedCfg[]>([]);
|
||||
const [showCfg, setShowCfg] = useState(false);
|
||||
const [cfgMsg, setCfgMsg] = useState('');
|
||||
// Items kept per feed. Held as a string so the field can be cleared while
|
||||
// typing; the bounds come from the API, so there is one definition of them.
|
||||
const [maxItems, setMaxItems] = useState('30');
|
||||
const [limits, setLimits] = useState({ def: 30, min: 1, max: 500 });
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [f, me] = await Promise.all([
|
||||
api.get('/api/v1/advisories/feeds').catch(() => ({ data: { feeds: [], fetched_at: null } })),
|
||||
api.get('/auth/me').catch(() => ({ data: {} })),
|
||||
]);
|
||||
setFeeds(f.data?.feeds || []);
|
||||
setFetchedAt(f.data?.fetched_at || null);
|
||||
setUserRole(me.data?.role || '');
|
||||
// config mirror for the admin editor (from the cache view — same rows)
|
||||
setCfg((f.data?.feeds || []).map((x: Feed) => ({ id: x.id, name: x.name, url: x.url, enabled: x.enabled })));
|
||||
setLimits({
|
||||
def: f.data?.max_items_default ?? 30,
|
||||
min: f.data?.max_items_min ?? 1,
|
||||
max: f.data?.max_items_max ?? 500,
|
||||
});
|
||||
setMaxItems(String(f.data?.max_items ?? f.data?.max_items_default ?? 30));
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
try { await api.post('/api/v1/advisories/feeds/refresh'); await load(); }
|
||||
catch (e: any) { dialog.alert(e?.response?.data?.detail || 'Refresh failed'); }
|
||||
finally { setRefreshing(false); }
|
||||
};
|
||||
|
||||
const saveCfg = async () => {
|
||||
// Say no here rather than let the server silently clamp — a limit that
|
||||
// quietly becomes something else is worse than a rejected one.
|
||||
const n = Number(maxItems);
|
||||
if (!maxItems.trim() || !Number.isFinite(n) || !Number.isInteger(n)
|
||||
|| n < limits.min || n > limits.max) {
|
||||
setCfgMsg(`Items per feed must be a whole number between ${limits.min} and ${limits.max}.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put('/api/v1/settings/advisory_feeds_config', { value: JSON.stringify(cfg) });
|
||||
await api.put('/api/v1/settings/advisory_feeds_max_items', { value: String(n) });
|
||||
setCfgMsg('Saved — refreshing feeds…');
|
||||
await api.post('/api/v1/advisories/feeds/refresh').catch(() => { });
|
||||
await load();
|
||||
setCfgMsg('Saved.');
|
||||
} catch (e: any) {
|
||||
setCfgMsg(e?.response?.data?.detail || 'Save failed');
|
||||
}
|
||||
};
|
||||
|
||||
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||
|
||||
if (loading) return <PageLoader label="Loading advisory feeds…" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Vendor and CERT advisories — read-only, not matched against your inventory.
|
||||
<span className="block mt-0.5 font-mono text-xs text-gray-400">
|
||||
{fetchedAt ? `Last fetch: ${new Date(fetchedAt).toLocaleString()}` : 'Not fetched yet'} · refreshed every 6 h
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex gap-2 flex-none">
|
||||
{userRole === 'admin' && (
|
||||
<button onClick={() => setShowCfg(!showCfg)} className="tv-btn">
|
||||
Configure
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={refresh} disabled={refreshing} className="tv-btn tv-btn--primary">
|
||||
{refreshing ? 'Refreshing…' : 'Refresh feeds'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin: feed configuration */}
|
||||
{showCfg && userRole === 'admin' && (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-4 mb-6">
|
||||
<h3 className="text-sm font-bold font-mono text-gray-900 mb-2">Feed configuration</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">Enable/disable sources or add a custom RSS/Atom URL. Feeds with DOCTYPE/ENTITY declarations are refused (XXE protection).</p>
|
||||
<div className="space-y-2">
|
||||
{cfg.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={f.enabled} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, enabled: e.target.checked }; setCfg(n); }} className="h-4 w-4 rounded border-gray-300 text-truevuln-blue" />
|
||||
<input type="text" value={f.name} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, name: e.target.value }; setCfg(n); }} className="w-64 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
|
||||
<input type="text" value={f.url} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, url: e.target.value }; setCfg(n); }} className="flex-1 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
|
||||
<button onClick={() => setCfg(cfg.filter((_, j) => j !== i))} className="text-red-600 text-xs px-2">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-gray-100">
|
||||
<label htmlFor="max-items" className="text-xs font-mono text-gray-700">Items per feed</label>
|
||||
<input
|
||||
id="max-items" type="number" inputMode="numeric"
|
||||
min={limits.min} max={limits.max} step={1}
|
||||
value={maxItems}
|
||||
onChange={(e) => setMaxItems(e.target.value)}
|
||||
className="w-24 rounded-md border-gray-300 text-xs font-mono h-8 px-2"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
{limits.min}–{limits.max}, default {limits.def}. Applies to every feed;
|
||||
takes effect on the next refresh. A feed that publishes fewer simply returns fewer.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button onClick={() => setCfg([...cfg, { id: `custom-${Date.now()}`, name: 'Custom feed', url: '', enabled: true }])} className="text-xs font-mono px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">+ Add feed</button>
|
||||
<button onClick={saveCfg} className="text-xs font-mono px-3 py-1.5 bg-truevuln-blue text-white rounded-md hover:bg-blue-600">Save</button>
|
||||
{cfgMsg && <span className="text-xs font-mono text-gray-500">{cfgMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feeds.filter(f => f.enabled).length === 0 && (
|
||||
<p className="text-sm text-gray-400 font-mono">No feed enabled.</p>
|
||||
)}
|
||||
{feeds.filter(f => f.enabled).map((f) => (
|
||||
<div key={f.id} className="bg-white border border-gray-200 shadow-sm rounded-sm mb-4">
|
||||
<button onClick={() => setOpenFeed(openFeed === f.id ? null : f.id)} className="w-full px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between text-left">
|
||||
<h3 className="text-sm font-bold font-mono text-gray-900">{f.name}
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">{f.items.length} items</span>
|
||||
{f.error && <span className="ml-2 text-xs text-red-600">fetch failed: {f.error}</span>}
|
||||
</h3>
|
||||
<span className="text-gray-400 text-xs">{openFeed === f.id ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{(openFeed === f.id || feeds.filter(x => x.enabled).length <= 2) && (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{/* No display cap here: how many items a feed keeps is the
|
||||
operator's "Items per feed" setting, and the header counts
|
||||
f.items.length — a second, hidden cap made the two disagree. */}
|
||||
{f.items.map((it, i) => (
|
||||
<li key={i} className="px-4 py-2 text-sm">
|
||||
<a href={it.link} target="_blank" rel="noopener noreferrer" className="font-medium text-truevuln-blue hover:underline">{it.title}</a>
|
||||
<span className="ml-2 text-xs text-gray-400 font-mono">{it.date}</span>
|
||||
{it.summary && <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{it.summary}</p>}
|
||||
</li>
|
||||
))}
|
||||
{f.items.length === 0 && !f.error && <li className="px-4 py-3 text-sm text-gray-400 font-mono">No items.</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
import { PageLoader } from "@/components/ui/Loading";
|
||||
|
||||
import { dialog } from "@/components/ui/dialog";
|
||||
|
||||
// Exploited (KEV) — every "actively exploited" catalog we have (CISA KEV,
|
||||
// ENISA EUVD, VulnCheck KEV), merged. What is open in the environment comes
|
||||
// first because that is what someone has to act on; the latest catalog
|
||||
// additions are the context below it. Catalogs are cached 24h each; the stamps
|
||||
// in the header are theirs, not the RSS feeds'.
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../../lib/api';
|
||||
import { kevBadge } from '../../../lib/kevBadge';
|
||||
|
||||
type KevSource = { id: string; label: string; url: string; updated_at: string | null; configured: boolean };
|
||||
|
||||
export default function KevPage() {
|
||||
const [kev, setKev] = useState<any[]>([]);
|
||||
// KEV view controls — '' = every source the backend merged.
|
||||
const [kevSource, setKevSource] = useState('');
|
||||
// Source list comes from the API so a new catalog shows up here (and in
|
||||
// the attribution line) without a second definition in the frontend.
|
||||
const [kevSources, setKevSources] = useState<KevSource[]>([]);
|
||||
const [onlyMine, setOnlyMine] = useState(false);
|
||||
const [alerts, setAlerts] = useState<any>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [alertCfg, setAlertCfg] = useState({ enabled: true, recipients: '' });
|
||||
const [alertCfgMsg, setAlertCfgMsg] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userRole, setUserRole] = useState('');
|
||||
|
||||
const kevUrl = () => {
|
||||
const p = new URLSearchParams({ limit: '15' });
|
||||
if (kevSource) p.set('sources', kevSource);
|
||||
if (onlyMine) p.set('in_inventory_only', 'true');
|
||||
return `/api/v1/advisories/kev-recent?${p}`;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [k, me, al] = await Promise.all([
|
||||
api.get(kevUrl()).catch(() => ({ data: { items: [] } })),
|
||||
api.get('/auth/me').catch(() => ({ data: {} })),
|
||||
api.get('/api/v1/advisories/kev-alerts').catch(() => ({ data: null })),
|
||||
]);
|
||||
setKev(k.data?.items || []);
|
||||
setKevSources(k.data?.sources || []);
|
||||
setAlerts(al.data || null);
|
||||
if (al.data) setAlertCfg({
|
||||
enabled: al.data.enabled !== false,
|
||||
recipients: al.data.recipients_setting || '',
|
||||
});
|
||||
setUserRole(me.data?.role || '');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
// Source / inventory filter changes refetch only the KEV list — the alert
|
||||
// panel is unaffected by them.
|
||||
useEffect(() => {
|
||||
let stale = false;
|
||||
api.get(kevUrl())
|
||||
.then((r) => { if (!stale) setKev(r.data?.items || []); })
|
||||
.catch(() => { });
|
||||
return () => { stale = true; };
|
||||
}, [kevSource, onlyMine]);
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const r = await api.post('/api/v1/advisories/kev/refresh');
|
||||
const failed = Object.entries(r.data || {}).filter(([, v]: any) => v?.error);
|
||||
if (failed.length) dialog.alert(failed.map(([s, v]: any) => `${s}: ${v.error}`).join('\n'));
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
dialog.alert(e?.response?.data?.detail || 'Refresh failed');
|
||||
} finally { setRefreshing(false); }
|
||||
};
|
||||
|
||||
const saveAlertCfg = async () => {
|
||||
try {
|
||||
await api.put('/api/v1/settings/kev_alert_enabled', { value: alertCfg.enabled ? 'true' : 'false' });
|
||||
await api.put('/api/v1/settings/kev_alert_recipients', { value: alertCfg.recipients.trim() });
|
||||
setAlertCfgMsg('Saved.');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setAlertCfgMsg(e?.response?.data?.detail || 'Save failed');
|
||||
}
|
||||
};
|
||||
|
||||
const sendAlerts = async () => {
|
||||
if (!await dialog.confirm('Send the KEV alert mail now to the configured recipients?')) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const r = await api.post('/api/v1/advisories/kev-alerts/run');
|
||||
const d = r.data || {};
|
||||
dialog.alert(d.skipped
|
||||
? `Nothing sent: ${d.skipped}`
|
||||
: `Sent ${d.emails_sent} mail(s) covering ${d.alerts} CVE(s) on ${d.assets} system(s).`
|
||||
+ (d.emails_failed ? ` ${d.emails_failed} failed — check the notification log.` : ''));
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
dialog.alert(e?.response?.data?.detail || 'Sending failed');
|
||||
} finally { setSending(false); }
|
||||
};
|
||||
|
||||
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||
|
||||
if (loading) return <PageLoader label="Loading KEV…" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
CVEs confirmed as exploited in the wild, matched against your inventory.
|
||||
<span className="block mt-0.5 font-mono text-xs text-gray-400">
|
||||
Catalogs updated:{' '}
|
||||
{kevSources.map((s) => `${s.label} ${!s.configured ? 'not configured'
|
||||
: s.updated_at ? new Date(s.updated_at).toLocaleString() : 'never'}`).join(' · ')}
|
||||
{' '}· cached 24 h
|
||||
</span>
|
||||
</p>
|
||||
{canEdit && (
|
||||
<button onClick={refresh} disabled={refreshing} className="tv-btn tv-btn--primary flex-none">
|
||||
{refreshing ? 'Refreshing…' : 'Refresh catalogs'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Exploited in the wild AND open here — what the alert mail carries. */}
|
||||
{alerts && (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-red-50 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold font-mono text-red-800">
|
||||
Exploited & open in your environment
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Alert mail {alerts.enabled ? 'enabled' : 'disabled'} · checked hourly ·
|
||||
{' '}{alerts.pending} pending · recipients: {(alerts.recipients || []).join(', ') || 'none configured'}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button onClick={sendAlerts} disabled={sending}
|
||||
className="rounded-md bg-red-700 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-red-800 disabled:opacity-50">
|
||||
{sending ? 'Sending…' : 'Send alert mail now'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin config — the alert mail points recipients here. */}
|
||||
{userRole === 'admin' && (
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50/50 flex items-center gap-3 flex-wrap text-xs font-mono">
|
||||
<label className="flex items-center gap-1">
|
||||
<input type="checkbox" checked={alertCfg.enabled}
|
||||
onChange={(e) => setAlertCfg({ ...alertCfg, enabled: e.target.checked })}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-red-700" />
|
||||
Alerting enabled
|
||||
</label>
|
||||
<label className="flex items-center gap-1 flex-1 min-w-[280px]">
|
||||
Recipients
|
||||
<input type="text" value={alertCfg.recipients}
|
||||
onChange={(e) => setAlertCfg({ ...alertCfg, recipients: e.target.value })}
|
||||
placeholder="empty = notification defaults (admins)"
|
||||
className="flex-1 rounded-md border-gray-300 text-xs font-mono h-7 px-2" />
|
||||
</label>
|
||||
<button onClick={saveAlertCfg}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">Save</button>
|
||||
{alertCfgMsg && <span className="text-gray-500">{alertCfgMsg}</span>}
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{(alerts.items || []).length === 0 && (
|
||||
<li className="px-4 py-3 text-sm text-gray-400 font-mono">
|
||||
Nothing actively exploited is open on an active asset.
|
||||
</li>
|
||||
)}
|
||||
{(alerts.items || []).map((a: any) => (
|
||||
<li key={a.cve_id} className="px-4 py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<a href={`/vulnerabilities?cve_id=${a.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{a.cve_id}</a>
|
||||
<span className="flex-none text-xs font-mono">
|
||||
<span className="rounded px-1.5 py-0.5 bg-red-100 text-red-700 font-bold">{a.open_asset_count} OPEN</span>
|
||||
{a.patched_asset_count > 0 && <span className="ml-1 text-gray-400">{a.patched_asset_count} remediated</span>}
|
||||
{a.previously_notified && <span className="ml-2 text-gray-400">already alerted</span>}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 font-mono mt-0.5 truncate">
|
||||
{(a.hosts || []).map((h: any) => h.hostname).join(', ')}
|
||||
{a.hosts_truncated > 0 && ` … +${a.hosts_truncated} more`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Latest catalog additions — every KEV source merged. A CVE listed
|
||||
by several carries several badges instead of appearing twice. */}
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between gap-3 flex-wrap">
|
||||
<h3 className="text-sm font-bold font-mono text-gray-900">
|
||||
Latest KEV additions
|
||||
{/* Source attribution. VulnCheck's community licence
|
||||
requires it to be named prominently wherever its
|
||||
data is shown. */}
|
||||
<span className="ml-2 font-normal text-gray-500">
|
||||
{(kevSources.length ? kevSources.map((s) => s.label)
|
||||
: ['CISA KEV', 'ENISA EUVD']).join(' · ')} · RANSOM = known ransomware use
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-xs font-mono">
|
||||
<label className="flex items-center gap-1 text-gray-700">
|
||||
<input type="checkbox" checked={onlyMine} onChange={(e) => setOnlyMine(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-red-700" />
|
||||
Only in my inventory
|
||||
</label>
|
||||
<select value={kevSource} onChange={(e) => setKevSource(e.target.value)}
|
||||
className="rounded-md border-gray-300 text-xs font-mono h-7 py-0 pl-2 pr-7">
|
||||
<option value="">All sources</option>
|
||||
{kevSources.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.label} only</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{kev.length === 0 && <li className="px-4 py-3 text-sm text-gray-400 font-mono">
|
||||
{onlyMine ? 'Nothing actively exploited is open in your inventory.' : 'No KEV data yet — refresh the catalogs.'}
|
||||
</li>}
|
||||
{kev.map((k: any, i: number) => (
|
||||
<li key={i} className="px-4 py-2 flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<a href={`/vulnerabilities?cve_id=${k.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{k.cve_id}</a>
|
||||
{(k.sources || []).map((s: string) => (
|
||||
<span key={s}
|
||||
title={s === 'euvd' && k.euvd_id
|
||||
? `ENISA EUVD — exploited (${k.euvd_id})`
|
||||
: kevBadge(s).title}
|
||||
className={`ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono uppercase ${kevBadge(s).cls}`}>
|
||||
{kevBadge(s).short}
|
||||
</span>
|
||||
))}
|
||||
{/* Lead time is the reason VulnCheck is here at
|
||||
all: how many days it beat CISA by. */}
|
||||
{k.lead_days > 0 && (
|
||||
<span title={`VulnCheck KEV listed this ${k.lead_days} day(s) before CISA`}
|
||||
className="ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono bg-amber-50 text-amber-700">
|
||||
−{k.lead_days}d
|
||||
</span>
|
||||
)}
|
||||
{k.ransomware && (
|
||||
<span title="Known Ransomware campaign use"
|
||||
className="ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono bg-red-50 text-red-700">
|
||||
RANSOM
|
||||
</span>
|
||||
)}
|
||||
{/* CISA rows carry a short vuln NAME; EUVD-only
|
||||
rows have vendor/product plus a paragraph of
|
||||
description — so the short label first, and
|
||||
the description only as a clipped last resort. */}
|
||||
<span className="ml-2 text-gray-600" title={k.description || undefined}>
|
||||
{k.name || [k.vendor, k.product].filter(Boolean).join(' · ')
|
||||
|| (k.description || '').slice(0, 140)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-none text-xs font-mono">
|
||||
{k.in_inventory && (
|
||||
<span className={`rounded px-1.5 py-0.5 font-bold ${k.open_asset_count > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}
|
||||
title={`${k.open_asset_count} asset(s) still open, ${k.patched_asset_count} already remediated`}>
|
||||
{k.open_asset_count > 0 ? `IN INVENTORY · ${k.open_asset_count}` : 'REMEDIATED'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-400">{k.date_added || ''}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
// Threat Intel — what happens outside the environment, before any scanner
|
||||
// reports it. Two tabs on purpose, because they are different kinds of thing:
|
||||
// Exploited (KEV) — structured CVE list, matched against the inventory,
|
||||
// drives the alert mail. Something to act on.
|
||||
// Advisory Feeds — vendor / CERT RSS, not matched, no alert. Reading.
|
||||
// Each tab states its own freshness and has its own refresh; one stamp in a
|
||||
// shared header is what made the RSS time look like the KEV time.
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const TABS = [
|
||||
{ href: '/advisories/kev', label: 'Exploited (KEV)' },
|
||||
{ href: '/advisories/feeds', label: 'Advisory Feeds' },
|
||||
];
|
||||
|
||||
export default function ThreatIntelLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 font-mono">Threat Intel</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
What is exploited or disclosed out there, often before NVD / cvelistV5 or any scanner reports it.
|
||||
</p>
|
||||
<nav className="mt-4 mb-6 flex gap-6 border-b border-gray-200">
|
||||
{TABS.map((t) => (
|
||||
<Link key={t.href} href={t.href}
|
||||
className={`-mb-px border-b-2 pb-2 text-sm font-mono font-semibold ${pathname === t.href
|
||||
? 'border-truevuln-blue text-truevuln-blue'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-800'}`}>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,384 +1,7 @@
|
||||
"use client";
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Security Advisory Feeds — CISA KEV (actively exploited) plus configurable
|
||||
// RSS sources (ZDI / CERT-EU / BSI / Cisco / custom). These sources publish
|
||||
// ahead of NVD/cvelistV5, so this page is the early-warning surface.
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../lib/api';
|
||||
import { kevBadge } from '../../lib/kevBadge';
|
||||
|
||||
type FeedItem = { title: string; link: string; date: string; summary: string };
|
||||
type Feed = { id: string; name: string; url: string; enabled: boolean; items: FeedItem[]; error: string | null };
|
||||
type FeedCfg = { id: string; name: string; url: string; enabled: boolean };
|
||||
type KevSource = { id: string; label: string; url: string };
|
||||
|
||||
export default function AdvisoriesPage() {
|
||||
const [kev, setKev] = useState<any[]>([]);
|
||||
// KEV view controls — '' = every source the backend merged.
|
||||
const [kevSource, setKevSource] = useState('');
|
||||
// Source list comes from the API so a new catalog shows up here (and in
|
||||
// the attribution line) without a second definition in the frontend.
|
||||
const [kevSources, setKevSources] = useState<KevSource[]>([]);
|
||||
const [onlyMine, setOnlyMine] = useState(false);
|
||||
const [alerts, setAlerts] = useState<any>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [alertCfg, setAlertCfg] = useState({ enabled: true, recipients: '' });
|
||||
const [alertCfgMsg, setAlertCfgMsg] = useState('');
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [userRole, setUserRole] = useState('');
|
||||
const [openFeed, setOpenFeed] = useState<string | null>(null);
|
||||
// Admin config editor
|
||||
const [cfg, setCfg] = useState<FeedCfg[]>([]);
|
||||
const [showCfg, setShowCfg] = useState(false);
|
||||
const [cfgMsg, setCfgMsg] = useState('');
|
||||
// Items kept per feed. Held as a string so the field can be cleared while
|
||||
// typing; the bounds come from the API, so there is one definition of them.
|
||||
const [maxItems, setMaxItems] = useState('30');
|
||||
const [limits, setLimits] = useState({ def: 30, min: 1, max: 500 });
|
||||
|
||||
const kevUrl = () => {
|
||||
const p = new URLSearchParams({ limit: '15' });
|
||||
if (kevSource) p.set('sources', kevSource);
|
||||
if (onlyMine) p.set('in_inventory_only', 'true');
|
||||
return `/api/v1/advisories/kev-recent?${p}`;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [k, f, me, al] = await Promise.all([
|
||||
api.get(kevUrl()).catch(() => ({ data: { items: [] } })),
|
||||
api.get('/api/v1/advisories/feeds').catch(() => ({ data: { feeds: [], fetched_at: null } })),
|
||||
api.get('/auth/me').catch(() => ({ data: {} })),
|
||||
api.get('/api/v1/advisories/kev-alerts').catch(() => ({ data: null })),
|
||||
]);
|
||||
setKev(k.data?.items || []);
|
||||
setKevSources(k.data?.sources || []);
|
||||
setAlerts(al.data || null);
|
||||
if (al.data) setAlertCfg({
|
||||
enabled: al.data.enabled !== false,
|
||||
recipients: al.data.recipients_setting || '',
|
||||
});
|
||||
setFeeds(f.data?.feeds || []);
|
||||
setFetchedAt(f.data?.fetched_at || null);
|
||||
setUserRole(me.data?.role || '');
|
||||
// config mirror for the admin editor (from the cache view — same rows)
|
||||
setCfg((f.data?.feeds || []).map((x: Feed) => ({ id: x.id, name: x.name, url: x.url, enabled: x.enabled })));
|
||||
setLimits({
|
||||
def: f.data?.max_items_default ?? 30,
|
||||
min: f.data?.max_items_min ?? 1,
|
||||
max: f.data?.max_items_max ?? 500,
|
||||
});
|
||||
setMaxItems(String(f.data?.max_items ?? f.data?.max_items_default ?? 30));
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
// Source / inventory filter changes refetch only the KEV list — the RSS
|
||||
// feeds and the alert panel are unaffected by them.
|
||||
useEffect(() => {
|
||||
let stale = false;
|
||||
api.get(kevUrl())
|
||||
.then((r) => { if (!stale) setKev(r.data?.items || []); })
|
||||
.catch(() => { });
|
||||
return () => { stale = true; };
|
||||
}, [kevSource, onlyMine]);
|
||||
|
||||
const saveAlertCfg = async () => {
|
||||
try {
|
||||
await api.put('/api/v1/settings/kev_alert_enabled', { value: alertCfg.enabled ? 'true' : 'false' });
|
||||
await api.put('/api/v1/settings/kev_alert_recipients', { value: alertCfg.recipients.trim() });
|
||||
setAlertCfgMsg('Saved.');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setAlertCfgMsg(e?.response?.data?.detail || 'Save failed');
|
||||
}
|
||||
};
|
||||
|
||||
const sendAlerts = async () => {
|
||||
if (!confirm('Send the KEV alert mail now to the configured recipients?')) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const r = await api.post('/api/v1/advisories/kev-alerts/run');
|
||||
const d = r.data || {};
|
||||
alert(d.skipped
|
||||
? `Nothing sent: ${d.skipped}`
|
||||
: `Sent ${d.emails_sent} mail(s) covering ${d.alerts} CVE(s) on ${d.assets} system(s).`
|
||||
+ (d.emails_failed ? ` ${d.emails_failed} failed — check the notification log.` : ''));
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.detail || 'Sending failed');
|
||||
} finally { setSending(false); }
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
try { await api.post('/api/v1/advisories/feeds/refresh'); await load(); }
|
||||
catch (e: any) { alert(e?.response?.data?.detail || 'Refresh failed'); }
|
||||
finally { setRefreshing(false); }
|
||||
};
|
||||
|
||||
const saveCfg = async () => {
|
||||
// Say no here rather than let the server silently clamp — a limit that
|
||||
// quietly becomes something else is worse than a rejected one.
|
||||
const n = Number(maxItems);
|
||||
if (!maxItems.trim() || !Number.isFinite(n) || !Number.isInteger(n)
|
||||
|| n < limits.min || n > limits.max) {
|
||||
setCfgMsg(`Items per feed must be a whole number between ${limits.min} and ${limits.max}.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put('/api/v1/settings/advisory_feeds_config', { value: JSON.stringify(cfg) });
|
||||
await api.put('/api/v1/settings/advisory_feeds_max_items', { value: String(n) });
|
||||
setCfgMsg('Saved — refreshing feeds…');
|
||||
await api.post('/api/v1/advisories/feeds/refresh').catch(() => { });
|
||||
await load();
|
||||
setCfgMsg('Saved.');
|
||||
} catch (e: any) {
|
||||
setCfgMsg(e?.response?.data?.detail || 'Save failed');
|
||||
}
|
||||
};
|
||||
|
||||
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||
|
||||
if (loading) return <div className="p-8">Loading Advisories...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 font-mono">Security Advisory Feeds</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Early-warning sources that often publish before NVD / cvelistV5.
|
||||
{fetchedAt && <span className="ml-2 font-mono text-xs text-gray-400">Last fetch: {new Date(fetchedAt).toLocaleString()}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{userRole === 'admin' && (
|
||||
<button onClick={() => setShowCfg(!showCfg)} className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
|
||||
⚙️ Configure
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={refresh} disabled={refreshing} className="rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50">
|
||||
{refreshing ? 'Refreshing…' : 'Refresh now'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin: feed configuration */}
|
||||
{showCfg && userRole === 'admin' && (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-4 mb-6">
|
||||
<h3 className="text-sm font-bold font-mono text-gray-900 mb-2">Feed configuration</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">Enable/disable sources or add a custom RSS/Atom URL. Feeds with DOCTYPE/ENTITY declarations are refused (XXE protection).</p>
|
||||
<div className="space-y-2">
|
||||
{cfg.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={f.enabled} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, enabled: e.target.checked }; setCfg(n); }} className="h-4 w-4 rounded border-gray-300 text-truevuln-blue" />
|
||||
<input type="text" value={f.name} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, name: e.target.value }; setCfg(n); }} className="w-64 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
|
||||
<input type="text" value={f.url} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, url: e.target.value }; setCfg(n); }} className="flex-1 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
|
||||
<button onClick={() => setCfg(cfg.filter((_, j) => j !== i))} className="text-red-600 text-xs px-2">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-gray-100">
|
||||
<label htmlFor="max-items" className="text-xs font-mono text-gray-700">Items per feed</label>
|
||||
<input
|
||||
id="max-items" type="number" inputMode="numeric"
|
||||
min={limits.min} max={limits.max} step={1}
|
||||
value={maxItems}
|
||||
onChange={(e) => setMaxItems(e.target.value)}
|
||||
className="w-24 rounded-md border-gray-300 text-xs font-mono h-8 px-2"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
{limits.min}–{limits.max}, default {limits.def}. Applies to every feed;
|
||||
takes effect on the next refresh. A feed that publishes fewer simply returns fewer.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button onClick={() => setCfg([...cfg, { id: `custom-${Date.now()}`, name: 'Custom feed', url: '', enabled: true }])} className="text-xs font-mono px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">+ Add feed</button>
|
||||
<button onClick={saveCfg} className="text-xs font-mono px-3 py-1.5 bg-truevuln-blue text-white rounded-md hover:bg-blue-600">Save</button>
|
||||
{cfgMsg && <span className="text-xs font-mono text-gray-500">{cfgMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actively exploited — every KEV source we have, merged. A CVE
|
||||
listed by both carries both badges instead of appearing twice. */}
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-red-50 flex items-center justify-between gap-3 flex-wrap">
|
||||
<h3 className="text-sm font-bold font-mono text-red-800">
|
||||
Actively Exploited — KEV (latest additions)
|
||||
{/* Source attribution. VulnCheck's community licence
|
||||
requires it to be named prominently wherever its
|
||||
data is shown. */}
|
||||
<span className="ml-2 font-normal text-red-700/70">
|
||||
{(kevSources.length ? kevSources.map((s) => s.label)
|
||||
: ['CISA KEV', 'ENISA EUVD']).join(' · ')} · 🔒 = Ransomware use
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-xs font-mono">
|
||||
<label className="flex items-center gap-1 text-red-800">
|
||||
<input type="checkbox" checked={onlyMine} onChange={(e) => setOnlyMine(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-red-300 text-red-700" />
|
||||
Only in my inventory
|
||||
</label>
|
||||
<select value={kevSource} onChange={(e) => setKevSource(e.target.value)}
|
||||
className="rounded-md border-gray-300 text-xs font-mono h-7 py-0 pl-2 pr-7">
|
||||
<option value="">All sources</option>
|
||||
{kevSources.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.label} only</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{kev.length === 0 && <li className="px-4 py-3 text-sm text-gray-400 font-mono">
|
||||
{onlyMine ? 'Nothing actively exploited is open in your inventory.' : 'No KEV data yet — run Refresh Threat Intel.'}
|
||||
</li>}
|
||||
{kev.map((k: any, i: number) => (
|
||||
<li key={i} className="px-4 py-2 flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<a href={`/vulnerabilities?cve_id=${k.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{k.cve_id}</a>
|
||||
{(k.sources || []).map((s: string) => (
|
||||
<span key={s}
|
||||
title={s === 'euvd' && k.euvd_id
|
||||
? `ENISA EUVD — exploited (${k.euvd_id})`
|
||||
: kevBadge(s).title}
|
||||
className={`ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono uppercase ${kevBadge(s).cls}`}>
|
||||
{kevBadge(s).short}
|
||||
</span>
|
||||
))}
|
||||
{/* Lead time is the reason VulnCheck is here at
|
||||
all: how many days it beat CISA by. */}
|
||||
{k.lead_days > 0 && (
|
||||
<span title={`VulnCheck KEV listed this ${k.lead_days} day(s) before CISA`}
|
||||
className="ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono bg-amber-50 text-amber-700">
|
||||
−{k.lead_days}d
|
||||
</span>
|
||||
)}
|
||||
{k.ransomware && <span title="Known Ransomware campaign use" className="ml-1">🔒</span>}
|
||||
{/* CISA rows carry a short vuln NAME; EUVD-only
|
||||
rows have vendor/product plus a paragraph of
|
||||
description — so the short label first, and
|
||||
the description only as a clipped last resort. */}
|
||||
<span className="ml-2 text-gray-600" title={k.description || undefined}>
|
||||
{k.name || [k.vendor, k.product].filter(Boolean).join(' · ')
|
||||
|| (k.description || '').slice(0, 140)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-none text-xs font-mono">
|
||||
{k.in_inventory && (
|
||||
<span className={`rounded px-1.5 py-0.5 font-bold ${k.open_asset_count > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}
|
||||
title={`${k.open_asset_count} asset(s) still open, ${k.patched_asset_count} already remediated`}>
|
||||
{k.open_asset_count > 0 ? `IN INVENTORY · ${k.open_asset_count}` : 'REMEDIATED'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-400">{k.date_added || ''}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Immediate alerting: exploited in the wild AND open here. */}
|
||||
{alerts && (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold font-mono text-gray-900">
|
||||
KEV Alerting — actively exploited & open in your environment
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{alerts.enabled ? 'Enabled' : 'Disabled'} · checked hourly ·
|
||||
{' '}{alerts.pending} pending · recipients: {(alerts.recipients || []).join(', ') || 'none configured'}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button onClick={sendAlerts} disabled={sending}
|
||||
className="rounded-md bg-red-700 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-red-800 disabled:opacity-50">
|
||||
{sending ? 'Sending…' : 'Send alert mail now'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin config — the alert mail points recipients here. */}
|
||||
{userRole === 'admin' && (
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50/50 flex items-center gap-3 flex-wrap text-xs font-mono">
|
||||
<label className="flex items-center gap-1">
|
||||
<input type="checkbox" checked={alertCfg.enabled}
|
||||
onChange={(e) => setAlertCfg({ ...alertCfg, enabled: e.target.checked })}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-red-700" />
|
||||
Alerting enabled
|
||||
</label>
|
||||
<label className="flex items-center gap-1 flex-1 min-w-[280px]">
|
||||
Recipients
|
||||
<input type="text" value={alertCfg.recipients}
|
||||
onChange={(e) => setAlertCfg({ ...alertCfg, recipients: e.target.value })}
|
||||
placeholder="empty = notification defaults (admins)"
|
||||
className="flex-1 rounded-md border-gray-300 text-xs font-mono h-7 px-2" />
|
||||
</label>
|
||||
<button onClick={saveAlertCfg}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">Save</button>
|
||||
{alertCfgMsg && <span className="text-gray-500">{alertCfgMsg}</span>}
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{(alerts.items || []).length === 0 && (
|
||||
<li className="px-4 py-3 text-sm text-gray-400 font-mono">
|
||||
Nothing actively exploited is open on an active asset. 🎉
|
||||
</li>
|
||||
)}
|
||||
{(alerts.items || []).map((a: any) => (
|
||||
<li key={a.cve_id} className="px-4 py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<a href={`/vulnerabilities?cve_id=${a.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{a.cve_id}</a>
|
||||
<span className="flex-none text-xs font-mono">
|
||||
<span className="rounded px-1.5 py-0.5 bg-red-100 text-red-700 font-bold">{a.open_asset_count} OPEN</span>
|
||||
{a.patched_asset_count > 0 && <span className="ml-1 text-gray-400">{a.patched_asset_count} remediated</span>}
|
||||
{a.previously_notified && <span className="ml-2 text-gray-400">already alerted</span>}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 font-mono mt-0.5 truncate">
|
||||
{(a.hosts || []).map((h: any) => h.hostname).join(', ')}
|
||||
{a.hosts_truncated > 0 && ` … +${a.hosts_truncated} more`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RSS feeds */}
|
||||
{feeds.filter(f => f.enabled).map((f) => (
|
||||
<div key={f.id} className="bg-white border border-gray-200 shadow-sm rounded-sm mb-4">
|
||||
<button onClick={() => setOpenFeed(openFeed === f.id ? null : f.id)} className="w-full px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between text-left">
|
||||
<h3 className="text-sm font-bold font-mono text-gray-900">{f.name}
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">{f.items.length} items</span>
|
||||
{f.error && <span className="ml-2 text-xs text-red-600">fetch failed: {f.error}</span>}
|
||||
</h3>
|
||||
<span className="text-gray-400 text-xs">{openFeed === f.id ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{(openFeed === f.id || feeds.filter(x => x.enabled).length <= 2) && (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{/* No display cap here: how many items a feed keeps is the
|
||||
operator's "Items per feed" setting, and the header counts
|
||||
f.items.length — a second, hidden cap made the two disagree. */}
|
||||
{f.items.map((it, i) => (
|
||||
<li key={i} className="px-4 py-2 text-sm">
|
||||
<a href={it.link} target="_blank" rel="noopener noreferrer" className="font-medium text-truevuln-blue hover:underline">{it.title}</a>
|
||||
<span className="ml-2 text-xs text-gray-400 font-mono">{it.date}</span>
|
||||
{it.summary && <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{it.summary}</p>}
|
||||
</li>
|
||||
))}
|
||||
{f.items.length === 0 && !f.error && <li className="px-4 py-3 text-sm text-gray-400 font-mono">No items.</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
// Threat Intel lives in two tabs (see layout.tsx); the bare URL — the old
|
||||
// Advisories page, still bookmarked and linked — opens the KEV tab.
|
||||
export default function ThreatIntelIndex() {
|
||||
redirect('/advisories/kev');
|
||||
}
|
||||
|
||||
+124
-57
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
import { PageLoader } from "@/components/ui/Loading";
|
||||
|
||||
import { dialog } from "@/components/ui/dialog";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../lib/api';
|
||||
import { formatScanStats } from '../../lib/scanStats';
|
||||
import { Asset, UserInfo, Group } from '../../types';
|
||||
import Link from 'next/link';
|
||||
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon, MagnifyingGlassIcon, ListBulletIcon, ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
||||
import Pagination from '../../components/shared/Pagination';
|
||||
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon, MagnifyingGlassIcon, ListBulletIcon } from '@heroicons/react/24/outline';
|
||||
import { UserCircleIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
export default function AssetsPage() {
|
||||
@@ -21,6 +25,9 @@ export default function AssetsPage() {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [sourceFilter, setSourceFilter] = useState('');
|
||||
// Container images (one asset each) are hidden by default: the list is for
|
||||
// hosts, a Docker host shows its images via its "N images" badge.
|
||||
const [showContainers, setShowContainers] = useState(false);
|
||||
// Table sort state
|
||||
const [sortBy, setSortBy] = useState<string>('hostname');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
@@ -30,6 +37,8 @@ export default function AssetsPage() {
|
||||
// Installed-software (on-demand) modal
|
||||
const [softwareReport, setSoftwareReport] = useState<any | null>(null);
|
||||
const [softwareLoading, setSoftwareLoading] = useState<number | null>(null);
|
||||
// Container images on a Docker host (Dockhand) modal
|
||||
const [containersReport, setContainersReport] = useState<any | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
// Pagination
|
||||
@@ -71,6 +80,7 @@ export default function AssetsPage() {
|
||||
if (searchText) params.search = searchText;
|
||||
if (showInactive) params.include_inactive = true;
|
||||
if (sourceFilter) params.source = sourceFilter;
|
||||
if (!showContainers) params.include_containers = false;
|
||||
params.sort_by = sortBy;
|
||||
params.sort_order = sortOrder;
|
||||
params.limit = pageSize;
|
||||
@@ -131,16 +141,15 @@ export default function AssetsPage() {
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchText, showInactive, sortBy, sortOrder, pageSize, sourceFilter]);
|
||||
}, [searchText, showInactive, sortBy, sortOrder, pageSize, sourceFilter, showContainers]);
|
||||
|
||||
// Fetch on page / filter / search change (debounced for typing).
|
||||
useEffect(() => {
|
||||
const t = setTimeout(fetchAssets, searchText ? 300 : 0);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize, sortBy, sortOrder, showInactive, searchText, sourceFilter]);
|
||||
}, [page, pageSize, sortBy, sortOrder, showInactive, searchText, sourceFilter, showContainers]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
@@ -173,7 +182,7 @@ export default function AssetsPage() {
|
||||
} catch (error: any) {
|
||||
console.error("Failed to save asset:", error);
|
||||
const msg = error.response?.data?.detail || "Failed to save asset.";
|
||||
alert(`Error: ${msg}`);
|
||||
dialog.alert(`Error: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,6 +227,14 @@ export default function AssetsPage() {
|
||||
['Intune device ID', asset.intune_device_id],
|
||||
['Defender machine ID', asset.defender_machine_id],
|
||||
['Wazuh agent ID', asset.wazuh_agent_id],
|
||||
['Intune serial', asset.intune_serial],
|
||||
['Nessus host UUID', asset.nessus_host_uuid],
|
||||
['VMware UUID', asset.vmware_uuid],
|
||||
['ESXi host serial', asset.vmware_serial],
|
||||
['IGEL unit ID (MAC)', asset.igel_unit_id],
|
||||
['IGEL serial', asset.igel_serial],
|
||||
['Netdisco device IP', asset.netdisco_device_ip],
|
||||
['Netdisco serial', asset.netdisco_serial],
|
||||
] as [string, string | null | undefined][]).filter(([, v]) => !!v) as [string, string][]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
@@ -228,7 +245,7 @@ export default function AssetsPage() {
|
||||
const res = await api.get(`/api/v1/assets/${asset.id}/coverage-gap`);
|
||||
setGapReport(res.data);
|
||||
} catch (e: any) {
|
||||
alert(`Coverage-gap failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
dialog.alert(`Coverage-gap failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setGapLoading(null);
|
||||
}
|
||||
@@ -238,11 +255,11 @@ export default function AssetsPage() {
|
||||
setRescanLoading(id);
|
||||
try {
|
||||
const res = await api.post(`/api/v1/assets/${id}/rescan`);
|
||||
alert(res.data.message);
|
||||
dialog.alert(res.data.message);
|
||||
fetchAssets();
|
||||
} catch (error: any) {
|
||||
console.error("Rescan failed:", error);
|
||||
alert(error.response?.data?.detail || "Rescan failed.");
|
||||
dialog.alert(error.response?.data?.detail || "Rescan failed.");
|
||||
} finally {
|
||||
setRescanLoading(null);
|
||||
}
|
||||
@@ -254,12 +271,21 @@ export default function AssetsPage() {
|
||||
const res = await api.get(`/api/v1/assets/${asset.id}/software`);
|
||||
setSoftwareReport(res.data);
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Could not load installed software.');
|
||||
dialog.alert(error.response?.data?.detail || 'Could not load installed software.');
|
||||
} finally {
|
||||
setSoftwareLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowContainers = async (hostId: number) => {
|
||||
try {
|
||||
const res = await api.get(`/api/v1/assets/${hostId}/containers`);
|
||||
setContainersReport(res.data);
|
||||
} catch (error: any) {
|
||||
dialog.alert(error.response?.data?.detail || 'Could not load container images.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppRescan = async (asset: Asset) => {
|
||||
setAppScanLoading(asset.id);
|
||||
try {
|
||||
@@ -267,12 +293,12 @@ export default function AssetsPage() {
|
||||
// Same counters the global scan button reports — this used to name
|
||||
// three of them and drop the rest (FP-suppressed, pruned packages).
|
||||
const extra = formatScanStats(res.data, ['assets', 'findings', 'new', 'errors']);
|
||||
alert(`App CVE re-scan done for ${asset.hostname}: `
|
||||
dialog.alert(`App CVE re-scan done for ${asset.hostname}: `
|
||||
+ `${res.data?.findings ?? 0} findings (${res.data?.new ?? 0} new)`
|
||||
+ (extra ? ` · ${extra}` : '') + '.');
|
||||
fetchAssets();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'App re-scan failed.');
|
||||
dialog.alert(error.response?.data?.detail || 'App re-scan failed.');
|
||||
} finally {
|
||||
setAppScanLoading(null);
|
||||
}
|
||||
@@ -280,10 +306,10 @@ export default function AssetsPage() {
|
||||
|
||||
const handleNessusRescan = async (asset: Asset) => {
|
||||
if (!asset.ip_address) {
|
||||
alert('Asset has no IP address — Nessus cannot target it.');
|
||||
dialog.alert('Asset has no IP address — Nessus cannot target it.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(
|
||||
if (!await dialog.confirm(
|
||||
`Launch a targeted Nessus scan for ${asset.hostname} (${asset.ip_address})?\n\n` +
|
||||
`Uses the scan configured in Settings → Tenable Nessus (default_scan_ids), ` +
|
||||
`but targets only this host's IP. After the scan completes in Nessus, ` +
|
||||
@@ -295,23 +321,23 @@ export default function AssetsPage() {
|
||||
const res = await api.post('/api/v1/vulnerabilities/nessus/scan-host', {
|
||||
asset_id: asset.id,
|
||||
});
|
||||
alert(res.data.message || 'Nessus scan launched successfully.');
|
||||
dialog.alert(res.data.message || 'Nessus scan launched successfully.');
|
||||
} catch (error: any) {
|
||||
const detail = error.response?.data?.detail || 'Failed to launch Nessus scan.';
|
||||
alert(`Nessus rescan failed: ${detail}`);
|
||||
dialog.alert(`Nessus rescan failed: ${detail}`);
|
||||
} finally {
|
||||
setNessusRescanLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAsset = async (id: number) => {
|
||||
if (!confirm("Are you sure you want to delete this asset?")) return;
|
||||
if (!await dialog.confirm("Are you sure you want to delete this asset?", { tone: "danger", confirmLabel: "Delete asset" })) return;
|
||||
try {
|
||||
await api.delete(`/api/v1/assets/${id}`);
|
||||
fetchAssets();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete asset:", error);
|
||||
alert("Failed to delete asset.");
|
||||
dialog.alert("Failed to delete asset.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -342,7 +368,7 @@ export default function AssetsPage() {
|
||||
fetchAssets();
|
||||
} catch (error) {
|
||||
console.error("Bulk update failed", error);
|
||||
alert("Bulk update failed.");
|
||||
dialog.alert("Bulk update failed.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -369,6 +395,7 @@ export default function AssetsPage() {
|
||||
if (searchText) params.search = searchText;
|
||||
if (sourceFilter) params.source = sourceFilter;
|
||||
if (showInactive) params.include_inactive = true;
|
||||
if (!showContainers) params.include_containers = false;
|
||||
const response = await api.get('/api/v1/assets/export', { params, responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
@@ -385,13 +412,13 @@ export default function AssetsPage() {
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Failed to export assets. Please ensure you are logged in.");
|
||||
dialog.alert("Failed to export assets. Please ensure you are logged in.");
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8">Loading Assets...</div>;
|
||||
if (loading) return <PageLoader label="Loading assets…" />;
|
||||
|
||||
// Mutating actions require editor+ (rescans/edit) or admin (delete) server-
|
||||
// side; hide them from read-only users so they don't get a 403 alert.
|
||||
@@ -437,6 +464,8 @@ export default function AssetsPage() {
|
||||
<option value="INTUNE">Intune / Defender</option>
|
||||
<option value="VCENTER">vCenter / ESXi</option>
|
||||
<option value="IGEL">IGEL UMS</option>
|
||||
<option value="NETDISCO">Netdisco</option>
|
||||
<option value="CONTAINER">Container images</option>
|
||||
<option value="MANUAL">Manual</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="INACTIVE assets are always shown (amber badge). Tick to also show operator-retired DECOMMISSIONED assets.">
|
||||
@@ -448,33 +477,41 @@ export default function AssetsPage() {
|
||||
/>
|
||||
Show decommissioned
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="Container images are one asset each. Hidden by default; a Docker host lists its images via its images badge.">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showContainers}
|
||||
onChange={(e) => setShowContainers(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
|
||||
/>
|
||||
Show container images
|
||||
</label>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm('Refresh network-exposure scores from Wazuh syscollector ports for all assets?')) return;
|
||||
if (!await dialog.confirm('Refresh network-exposure scores from Wazuh syscollector ports for all assets?')) return;
|
||||
try {
|
||||
const r = await api.post('/api/v1/assets/refresh-exposure');
|
||||
alert(`Exposure refreshed: ${r.data?.exposed || 0} of ${r.data?.assets || 0} assets have exposed listeners.`);
|
||||
dialog.alert(`Exposure refreshed: ${r.data?.exposed || 0} of ${r.data?.assets || 0} assets have exposed listeners.`);
|
||||
fetchAssets();
|
||||
} catch (e: any) {
|
||||
alert(`Failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
dialog.alert(`Failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center rounded-sm border border-purple-500 text-purple-700 px-3 py-2 text-xs font-mono font-semibold hover:bg-purple-50 whitespace-nowrap"
|
||||
className="tv-btn"
|
||||
title="Pull open listeners from Wazuh and recompute exposure scores"
|
||||
>
|
||||
⚡ Exposure
|
||||
Exposure
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCsv}
|
||||
disabled={exportLoading}
|
||||
className="inline-flex items-center rounded-sm border border-gray-300 text-gray-700 px-3 py-2 text-xs font-mono font-semibold hover:bg-gray-50 whitespace-nowrap disabled:opacity-50"
|
||||
className="tv-btn"
|
||||
title="Export all matching assets as CSV — includes every field, ignores pagination"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-4 w-4 mr-1" />
|
||||
{exportLoading ? 'Exporting…' : 'Export CSV'}
|
||||
</button>
|
||||
{canEdit && (
|
||||
@@ -603,7 +640,7 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
))}
|
||||
<div className="text-gray-400">
|
||||
Set by the sync, read-only. The Entra device ID stays the same when a
|
||||
Set by the sync, read-only — what each sync finds this asset by. The Entra device ID stays the same when a
|
||||
device is deleted in Intune but still reported by Defender TVM under
|
||||
another name — use it to identify or exclude the device there.
|
||||
</div>
|
||||
@@ -728,6 +765,25 @@ export default function AssetsPage() {
|
||||
<Link href={`/vulnerabilities?asset_id=${asset.id}`} className="hover:underline">
|
||||
{asset.hostname}
|
||||
</Link>
|
||||
{!!asset.container_image_count && (
|
||||
<button
|
||||
onClick={() => handleShowContainers(asset.id)}
|
||||
title="Container images Dockhand reports on this Docker host"
|
||||
className="ml-2 inline-flex items-center rounded-md bg-sky-50 px-1.5 py-0.5 text-xs font-medium text-sky-700 ring-1 ring-inset ring-sky-200 hover:bg-sky-100"
|
||||
>
|
||||
{asset.container_image_count} image{asset.container_image_count === 1 ? '' : 's'}
|
||||
</button>
|
||||
)}
|
||||
{!!asset.runs_on?.length && (
|
||||
<div className="text-xs font-normal text-gray-500">
|
||||
on {asset.runs_on.map((h, i) => (
|
||||
<span key={h.id}>
|
||||
{i > 0 && ', '}
|
||||
<button onClick={() => handleShowContainers(h.id)} className="text-sky-700 hover:underline">{h.hostname}</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-gray-500">{asset.ip_address}</td>
|
||||
<td className="px-3 py-4 text-gray-500">
|
||||
@@ -948,35 +1004,8 @@ export default function AssetsPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm font-mono text-gray-600">
|
||||
<div>
|
||||
{total === 0 ? '0' : `${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)}`} of {total}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">Per page</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(parseInt(e.target.value, 10))}
|
||||
className="rounded-md border-gray-300 text-sm py-1 pl-2 pr-7 focus:border-truevuln-blue focus:ring-truevuln-blue"
|
||||
>
|
||||
{[50, 100, 250, 500, 1000].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPage(1)} disabled={page <= 1}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">«</button>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">‹ Prev</button>
|
||||
<span className="px-2">Page {page} / {totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">Next ›</button>
|
||||
<button onClick={() => setPage(totalPages)} disabled={page >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} pageSize={pageSize} total={total}
|
||||
onPageChange={setPage} onPageSizeChange={setPageSize} />
|
||||
</div>
|
||||
|
||||
{/* Coverage-gap modal — installed packages with no finding */}
|
||||
@@ -1020,6 +1049,44 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{containersReport && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setContainersReport(null)}>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-bold font-mono text-gray-900">Container images — {containersReport.hostname}</h3>
|
||||
<p className="text-xs text-gray-500 font-mono mt-1">
|
||||
{containersReport.images.length} images with findings, via Dockhand (images without findings are not in its export)
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4">
|
||||
{containersReport.images.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 font-mono">No container images linked to this host.</p>
|
||||
) : (
|
||||
<table className="min-w-full text-xs font-mono">
|
||||
<thead className="text-gray-500 uppercase">
|
||||
<tr><th className="text-left py-1">Image</th><th className="text-left py-1">Containers</th><th className="text-right py-1">Open</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{containersReport.images.map((i: any) => (
|
||||
<tr key={i.id}>
|
||||
<td className="py-1 pr-3">
|
||||
<Link href={`/vulnerabilities?asset_id=${i.id}`} className="text-truevuln-blue hover:underline">{i.image}</Link>
|
||||
{i.status !== 'active' && <span className="ml-1 text-amber-600">({i.status})</span>}
|
||||
</td>
|
||||
<td className="py-1 pr-3 text-gray-500">{i.containers || '—'}</td>
|
||||
<td className="py-1 text-right text-gray-900">{i.vulnerability_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 border-t border-gray-200 text-right">
|
||||
<button onClick={() => setContainersReport(null)} className="rounded-md bg-gray-100 px-4 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-200">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{softwareReport && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setSoftwareReport(null)}>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
import { PageLoader } from "@/components/ui/Loading";
|
||||
|
||||
import { dialog } from "@/components/ui/dialog";
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import api from '../../lib/api';
|
||||
import { ArrowPathIcon, CheckBadgeIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
import { CheckBadgeIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
import { InlineSpinner } from '@/components/ui/Loading';
|
||||
|
||||
type AssetSummary = {
|
||||
asset_id: number;
|
||||
@@ -175,7 +179,7 @@ function ComplianceInner() {
|
||||
});
|
||||
}
|
||||
}
|
||||
alert(msg);
|
||||
dialog.alert(msg);
|
||||
await fetchAll();
|
||||
} catch (e: any) {
|
||||
setErr(formatApiError(e, 'Impact CSV upload failed.'));
|
||||
@@ -194,12 +198,12 @@ function ComplianceInner() {
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
if (!confirm('Refresh SCA results for every Wazuh-linked asset? This pulls /sca/{agent_id} per agent.')) return;
|
||||
if (!await dialog.confirm('Refresh SCA results for every Wazuh-linked asset? This pulls /sca/{agent_id} per agent.')) return;
|
||||
setRefreshing(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.post('/api/v1/compliance/refresh');
|
||||
alert(`Compliance refreshed: ${r.data.assets_synced} assets, ${r.data.policies_synced} policy results.${(r.data.errors || []).length ? ' Some errors — see backend logs.' : ''}`);
|
||||
dialog.alert(`Compliance refreshed: ${r.data.assets_synced} assets, ${r.data.policies_synced} policy results.${(r.data.errors || []).length ? ' Some errors — see backend logs.' : ''}`);
|
||||
await fetchAll();
|
||||
} catch (e: any) {
|
||||
setErr(formatApiError(e, 'Refresh failed.'));
|
||||
@@ -241,7 +245,7 @@ function ComplianceInner() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center font-mono text-gray-500">Loading compliance data…</div>;
|
||||
if (loading) return <PageLoader label="Loading compliance data…" />;
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 sm:p-6 lg:p-8">
|
||||
@@ -260,8 +264,7 @@ function ComplianceInner() {
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50 font-mono"
|
||||
>
|
||||
<ArrowPathIcon className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
{refreshing ? 'Refreshing…' : 'Refresh All'}
|
||||
{refreshing ? <><InlineSpinner /> Refreshing…</> : 'Refresh All'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -348,7 +351,7 @@ function ComplianceInner() {
|
||||
onClick={recomputeURS}
|
||||
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 font-mono"
|
||||
>
|
||||
↻ recompute
|
||||
recompute
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,7 +490,7 @@ function ComplianceInner() {
|
||||
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 font-mono"
|
||||
title="Re-fetch from Wazuh"
|
||||
>
|
||||
↻ refresh
|
||||
refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedAsset(null)}
|
||||
@@ -536,7 +539,7 @@ function ComplianceInner() {
|
||||
|
||||
export default function CompliancePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-center font-mono text-gray-500">Loading compliance data…</div>}>
|
||||
<Suspense fallback={<PageLoader label="Loading compliance data…" />}>
|
||||
<ComplianceInner />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -224,6 +224,9 @@ html[data-theme="mid"] .scroll-visible-x::-webkit-scrollbar-thumb, html[data-the
|
||||
* light-gray under light text) ---- */
|
||||
html[data-theme="mid"] .bg-gray-50\/50, html[data-theme="dark"] .bg-gray-50\/50 { background-color: var(--tv-surface-2); }
|
||||
html[data-theme="mid"] .bg-white\/50, html[data-theme="dark"] .bg-white\/50 { background-color: var(--tv-surface-3); }
|
||||
/* settings disclosure cards: the divider exists only while the card is open,
|
||||
* so it carries its own variant class and misses the .border-gray-100 remap */
|
||||
html[data-theme="mid"] .group-open\:border-gray-100, html[data-theme="dark"] .group-open\:border-gray-100 { border-color: var(--tv-border-soft); }
|
||||
/* vulnerabilities list: sticky header band uses an arbitrary value of the
|
||||
* light page background (#F3F4F6 at 95%) */
|
||||
html[data-theme="mid"] .bg-\[\#F3F4F6\]\/95, html[data-theme="dark"] .bg-\[\#F3F4F6\]\/95 { background-color: var(--tv-bg); }
|
||||
@@ -246,3 +249,261 @@ html[data-theme="mid"] .border-indigo-200, html[data-theme="dark"] .border-indig
|
||||
|
||||
/* daisyui base-200 (used by a few shells) */
|
||||
html[data-theme="mid"] .bg-base-200, html[data-theme="dark"] .bg-base-200 { background-color: var(--tv-surface-2); }
|
||||
|
||||
/* ============================================================
|
||||
* DESIGN TOKENS (OKLCH) + component classes.
|
||||
*
|
||||
* The remap layer above retro-fits dark mode onto hardcoded light
|
||||
* utilities. Everything below is the opposite direction: tokens
|
||||
* first, components built only from tokens — so .tv-* classes
|
||||
* follow light/mid/dark on their own and need no remap entry.
|
||||
*
|
||||
* Palette rule: neutrals carry the UI, brand marks the one primary
|
||||
* action, and semantic hues are reserved for STATE (danger / warn /
|
||||
* ok / info). Anything that is merely an identity label (scanner
|
||||
* name, package, PoC count) stays neutral — see DESIGN.md.
|
||||
* ============================================================ */
|
||||
:root {
|
||||
/* brand — the existing TrueVuln blue, expressed in OKLCH */
|
||||
--tv-brand: oklch(0.52 0.21 258);
|
||||
--tv-brand-hover: oklch(0.45 0.21 258);
|
||||
--tv-brand-solid: oklch(0.52 0.21 258);
|
||||
--tv-brand-soft: oklch(0.96 0.030 258);
|
||||
--tv-brand-ring: oklch(0.82 0.085 258);
|
||||
--tv-brand-on: #ffffff;
|
||||
|
||||
/* neutrals — chroma ~0, one hue so greys stay in family */
|
||||
--tv-fg: oklch(0.26 0.015 258);
|
||||
--tv-fg-soft: oklch(0.43 0.012 258);
|
||||
--tv-fg-muted: oklch(0.58 0.010 258);
|
||||
--tv-line: oklch(0.90 0.005 258);
|
||||
--tv-line-strong: oklch(0.83 0.008 258);
|
||||
--tv-btn-bg: oklch(1 0 0);
|
||||
--tv-btn-bg-hover:oklch(0.965 0.004 258);
|
||||
--tv-mute-fg: oklch(0.45 0.012 258);
|
||||
--tv-mute-bg: oklch(0.965 0.004 258);
|
||||
--tv-mute-bd: oklch(0.91 0.005 258);
|
||||
|
||||
/* semantic — state only, never decoration */
|
||||
--tv-danger: oklch(0.50 0.19 27);
|
||||
--tv-danger-bg: oklch(0.955 0.030 27);
|
||||
--tv-danger-bd: oklch(0.88 0.070 27);
|
||||
--tv-danger-solid:oklch(0.55 0.21 27);
|
||||
--tv-warn: oklch(0.50 0.13 72);
|
||||
--tv-warn-bg: oklch(0.960 0.045 82);
|
||||
--tv-warn-bd: oklch(0.88 0.080 82);
|
||||
--tv-ok: oklch(0.48 0.12 158);
|
||||
--tv-ok-bg: oklch(0.955 0.040 158);
|
||||
--tv-ok-bd: oklch(0.87 0.070 158);
|
||||
--tv-info: oklch(0.50 0.15 250);
|
||||
--tv-info-bg: oklch(0.955 0.035 250);
|
||||
--tv-info-bd: oklch(0.87 0.070 250);
|
||||
}
|
||||
|
||||
/* Dark modes: neutrals come from the surface scale defined above;
|
||||
* semantic fills become translucent tints so they sit ON the surface
|
||||
* instead of punching a pastel hole in it, and the foregrounds lift
|
||||
* to keep AA contrast on a dark ground. */
|
||||
html[data-theme="mid"],
|
||||
html[data-theme="dark"] {
|
||||
--tv-brand: oklch(0.66 0.16 258);
|
||||
--tv-brand-hover: oklch(0.58 0.21 258);
|
||||
--tv-brand-solid: oklch(0.50 0.21 258);
|
||||
--tv-brand-soft: color-mix(in oklab, oklch(0.66 0.16 258) 18%, transparent);
|
||||
--tv-brand-ring: color-mix(in oklab, oklch(0.66 0.16 258) 45%, transparent);
|
||||
|
||||
--tv-fg: var(--tv-text);
|
||||
--tv-fg-soft: var(--tv-text-soft);
|
||||
--tv-fg-muted: var(--tv-text-muted);
|
||||
--tv-line: var(--tv-border-soft);
|
||||
--tv-line-strong: var(--tv-border);
|
||||
--tv-btn-bg: var(--tv-surface-2);
|
||||
--tv-btn-bg-hover:var(--tv-surface-3);
|
||||
--tv-mute-fg: var(--tv-text-soft);
|
||||
--tv-mute-bg: var(--tv-surface-3);
|
||||
--tv-mute-bd: var(--tv-border-soft);
|
||||
|
||||
--tv-danger: oklch(0.74 0.15 27);
|
||||
--tv-danger-bg: color-mix(in oklab, oklch(0.65 0.19 27) 20%, transparent);
|
||||
--tv-danger-bd: color-mix(in oklab, oklch(0.65 0.19 27) 45%, transparent);
|
||||
--tv-danger-solid:oklch(0.55 0.21 27);
|
||||
--tv-warn: oklch(0.80 0.13 82);
|
||||
--tv-warn-bg: color-mix(in oklab, oklch(0.75 0.15 82) 18%, transparent);
|
||||
--tv-warn-bd: color-mix(in oklab, oklch(0.75 0.15 82) 40%, transparent);
|
||||
--tv-ok: oklch(0.76 0.14 158);
|
||||
--tv-ok-bg: color-mix(in oklab, oklch(0.70 0.15 158) 18%, transparent);
|
||||
--tv-ok-bd: color-mix(in oklab, oklch(0.70 0.15 158) 40%, transparent);
|
||||
--tv-info: oklch(0.75 0.12 250);
|
||||
--tv-info-bg: color-mix(in oklab, oklch(0.68 0.15 250) 20%, transparent);
|
||||
--tv-info-bd: color-mix(in oklab, oklch(0.68 0.15 250) 42%, transparent);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---- buttons: neutral by default, brand only for the primary ---- */
|
||||
.tv-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--tv-line);
|
||||
border-top-color: color-mix(in oklab, var(--tv-line) 70%, var(--tv-btn-bg));
|
||||
border-radius: 0.375rem;
|
||||
background: var(--tv-btn-bg);
|
||||
color: var(--tv-fg-soft);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1rem;
|
||||
white-space: nowrap;
|
||||
transition: background-color .12s, color .12s, border-color .12s;
|
||||
}
|
||||
.tv-btn:hover:not(:disabled) {
|
||||
background: var(--tv-btn-bg-hover);
|
||||
border-color: var(--tv-line-strong);
|
||||
color: var(--tv-fg);
|
||||
}
|
||||
.tv-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.tv-btn--primary {
|
||||
background: var(--tv-brand-solid);
|
||||
border-color: var(--tv-brand-solid);
|
||||
color: var(--tv-brand-on);
|
||||
}
|
||||
.tv-btn--danger {
|
||||
background: var(--tv-danger-solid);
|
||||
border-color: var(--tv-danger-solid);
|
||||
color: #fff;
|
||||
}
|
||||
.tv-btn--danger:hover:not(:disabled) {
|
||||
background: color-mix(in oklab, var(--tv-danger-solid) 85%, black);
|
||||
border-color: color-mix(in oklab, var(--tv-danger-solid) 85%, black);
|
||||
color: #fff;
|
||||
}
|
||||
.tv-btn--primary:hover:not(:disabled) {
|
||||
background: var(--tv-brand-hover);
|
||||
border-color: var(--tv-brand-hover);
|
||||
color: var(--tv-brand-on);
|
||||
}
|
||||
|
||||
/* result popover hanging under a job button */
|
||||
.tv-pop {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
margin-top: 0.25rem;
|
||||
width: 15rem;
|
||||
white-space: normal;
|
||||
border: 1px solid var(--tv-line-strong);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--tv-btn-bg);
|
||||
color: var(--tv-fg-soft);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 1px 2px oklch(0 0 0 / .10), 0 6px 16px -6px oklch(0 0 0 / .18);
|
||||
}
|
||||
|
||||
/* ---- filter chips: neutral when off, brand-tinted when on ----
|
||||
* :has() does the state — no JS, no per-filter hue. */
|
||||
.tv-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid var(--tv-line);
|
||||
border-radius: 9999px;
|
||||
background: var(--tv-btn-bg);
|
||||
color: var(--tv-fg-muted);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color .12s, color .12s, border-color .12s;
|
||||
}
|
||||
.tv-chip:hover { background: var(--tv-btn-bg-hover); color: var(--tv-fg); }
|
||||
.tv-chip:has(input:checked) {
|
||||
background: var(--tv-brand-soft);
|
||||
border-color: var(--tv-brand-ring);
|
||||
color: var(--tv-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tv-chip__box {
|
||||
accent-color: var(--tv-brand);
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border-radius: 0.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- tags: default neutral. A hue means STATE, nothing else. ---- */
|
||||
.tv-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--tv-mute-bd);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--tv-mute-bg);
|
||||
color: var(--tv-mute-fg);
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tv-tag--danger { background: var(--tv-danger-bg); border-color: var(--tv-danger-bd); color: var(--tv-danger); }
|
||||
.tv-tag--warn { background: var(--tv-warn-bg); border-color: var(--tv-warn-bd); color: var(--tv-warn); }
|
||||
.tv-tag--ok { background: var(--tv-ok-bg); border-color: var(--tv-ok-bd); color: var(--tv-ok); }
|
||||
.tv-tag--info { background: var(--tv-info-bg); border-color: var(--tv-info-bd); color: var(--tv-info); }
|
||||
/* solid = the one step above danger: weaponised / widespread */
|
||||
.tv-tag--alarm { background: var(--tv-danger-solid); border-color: var(--tv-danger-solid); color: #fff; }
|
||||
/* severity pill sits one size up — it is the column's primary signal */
|
||||
.tv-tag--lg { padding: 0.2rem 0.5rem; font-size: 0.7rem; letter-spacing: .02em; }
|
||||
|
||||
/* ---- score bars ---- */
|
||||
.tv-bar { background: var(--tv-mute-bg); border-radius: 9999px; overflow: hidden; }
|
||||
.tv-bar__hi { background: var(--tv-danger-solid); }
|
||||
.tv-bar__mid { background: oklch(0.68 0.15 60); }
|
||||
.tv-bar__low { background: oklch(0.62 0.13 158); }
|
||||
|
||||
/* ---- collapsible job-action drawer ---- */
|
||||
.tv-drawer { border-top: 1px solid var(--tv-line); }
|
||||
.tv-drawer > summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
color: var(--tv-fg-muted);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.tv-drawer > summary::-webkit-details-marker { display: none; }
|
||||
.tv-drawer > summary:hover { color: var(--tv-fg); }
|
||||
.tv-drawer > summary::before {
|
||||
content: "▸";
|
||||
transition: transform .15s;
|
||||
display: inline-block;
|
||||
}
|
||||
.tv-drawer[open] > summary::before { transform: rotate(90deg); }
|
||||
|
||||
/* ---- collapsible page section: same caret as the drawer, card header ---- */
|
||||
.tv-section > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.tv-section > summary::-webkit-details-marker { display: none; }
|
||||
.tv-section > summary::before {
|
||||
content: "▸";
|
||||
color: var(--tv-fg-muted);
|
||||
transition: transform .15s;
|
||||
display: inline-block;
|
||||
}
|
||||
.tv-section[open] > summary::before { transform: rotate(90deg); }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
import { PageLoader } from "@/components/ui/Loading";
|
||||
|
||||
import { dialog } from "@/components/ui/dialog";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import api from '../../lib/api';
|
||||
import { Group, UserInfo } from '../../types';
|
||||
import {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
PencilSquareIcon,
|
||||
UserGroupIcon,
|
||||
CheckBadgeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const GroupsPage = () => {
|
||||
@@ -20,19 +22,22 @@ const GroupsPage = () => {
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', description: '', user_ids: [] as number[] });
|
||||
const [status, setStatus] = useState<{ message: string, type: string }>({ message: '', type: '' });
|
||||
const router = useRouter();
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [gRes, uRes, dRes] = await Promise.all([
|
||||
api.get('/api/v1/groups'),
|
||||
api.get('/auth/users').catch(() => ({ data: [] })),
|
||||
api.get('/api/v1/groups/default').catch(() => ({ data: null }))
|
||||
api.get('/api/v1/groups/default')
|
||||
]);
|
||||
setGroups(gRes.data);
|
||||
setUsers(uRes.data);
|
||||
setDefaultGroupId(dRes.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data", error);
|
||||
// Groups is admin-only; the sidebar hides it, the URL does not.
|
||||
if ((error as { response?: { status?: number } })?.response?.status === 403) router.push('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -77,7 +82,7 @@ const GroupsPage = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this group?')) return;
|
||||
if (!await dialog.confirm('Are you sure you want to delete this group?', { tone: "danger", confirmLabel: "Delete group" })) return;
|
||||
try {
|
||||
await api.delete(`/api/v1/groups/${id}`);
|
||||
fetchData();
|
||||
@@ -109,7 +114,7 @@ const GroupsPage = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center font-mono">Loading Groups...</div>;
|
||||
if (loading) return <PageLoader label="Loading groups…" />;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
@@ -125,7 +130,6 @@ const GroupsPage = () => {
|
||||
onClick={() => handleOpenModal()}
|
||||
className="inline-flex items-center rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
@@ -173,7 +177,6 @@ const GroupsPage = () => {
|
||||
onClick={() => handleSetDefault(group.id)}
|
||||
className="mt-4 w-full flex justify-center items-center gap-2 px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded border border-blue-200"
|
||||
>
|
||||
<CheckBadgeIcon className="h-4 w-4" />
|
||||
Set as Default
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google"; // Keep fonts if desired, or replace with Inter as per design request
|
||||
import "./globals.css";
|
||||
import AppShell from "@/components/shared/shell/AppShell";
|
||||
import { DialogHost } from "@/components/ui/dialog";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -43,6 +44,9 @@ export default function RootLayout({
|
||||
<AppShell>
|
||||
{children}
|
||||
</AppShell>
|
||||
{/* One host for every alert/confirm/prompt in the app — see
|
||||
components/ui/dialog.tsx */}
|
||||
<DialogHost />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
EnvelopeIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
EyeIcon,
|
||||
XMarkIcon,
|
||||
ClockIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import api from '../../lib/api';
|
||||
import Pagination from '../../components/shared/Pagination';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface NotificationLog {
|
||||
@@ -30,12 +30,20 @@ export default function NotificationsPage() {
|
||||
const [logs, setLogs] = useState<NotificationLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previewLog, setPreviewLog] = useState<NotificationLog | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/api/v1/notifications/log');
|
||||
const response = await api.get('/api/v1/notifications/log', {
|
||||
params: { limit: pageSize, offset: (page - 1) * pageSize },
|
||||
});
|
||||
setLogs(response.data);
|
||||
const t = parseInt(response.headers['x-total-count'] ?? '0', 10);
|
||||
setTotal(Number.isNaN(t) ? response.data.length : t);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch notification logs", error);
|
||||
} finally {
|
||||
@@ -43,7 +51,7 @@ export default function NotificationsPage() {
|
||||
}
|
||||
};
|
||||
fetchLogs();
|
||||
}, []);
|
||||
}, [page, pageSize]);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
@@ -65,7 +73,7 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded-lg shadow-sm border border-gray-100 flex items-center gap-2">
|
||||
<EnvelopeIcon className="h-5 w-5 text-truevuln-blue" />
|
||||
<span className="text-sm font-bold text-gray-700">{logs.length} Notifications</span>
|
||||
<span className="text-sm font-bold text-gray-700">{total.toLocaleString()} Notifications</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +123,6 @@ export default function NotificationsPage() {
|
||||
onClick={() => setPreviewLog(log)}
|
||||
className="text-truevuln-blue hover:text-blue-900 flex items-center gap-1 ml-auto"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
</td>
|
||||
@@ -130,6 +137,9 @@ export default function NotificationsPage() {
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination page={page} pageSize={pageSize} total={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(n) => { setPageSize(n); setPage(1); }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+46
-10
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
import { PageLoader } from "@/components/ui/Loading";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ArrowDownRightIcon,
|
||||
SparklesIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import api from '../lib/api';
|
||||
import { SyncHealthBanner } from '../components/shared/SyncHealth';
|
||||
import { kevBadge } from '../lib/kevBadge';
|
||||
import { DashboardStats, Vulnerability, AIPriorityResponse } from '../types';
|
||||
import AIRecommendations from '@/components/AIRecommendations';
|
||||
@@ -31,6 +32,11 @@ function renderVulnWidget(opts: {
|
||||
// EOL widget: replace the (often empty) CPR column with the affected
|
||||
// asset's hostname, linked to that asset's EOL findings.
|
||||
assetColumn?: boolean;
|
||||
// The page renders as soon as the stat cards have their numbers, so a
|
||||
// widget can be on screen before its own request has answered. Empty then
|
||||
// means "not back yet", and it must NOT read as "No data." — that is the
|
||||
// one confusion this dashboard cannot afford.
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const { title, subtitle, vulns, onRowClick, viewAllHref } = opts;
|
||||
const labelField = opts.labelField || 'cve_id';
|
||||
@@ -129,7 +135,14 @@ function renderVulnWidget(opts: {
|
||||
</tr>
|
||||
))}
|
||||
{vulns.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-4 text-gray-500">No data.</td></tr>
|
||||
<tr><td colSpan={6} className="text-center py-4 text-gray-500">
|
||||
{opts.loading
|
||||
? <span className="inline-flex items-center gap-2 text-gray-400">
|
||||
<span className="h-3 w-3 rounded-full border-2 border-gray-300 border-t-truevuln-blue animate-spin" />
|
||||
Loading…
|
||||
</span>
|
||||
: 'No data.'}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -144,6 +157,9 @@ export default function Dashboard() {
|
||||
const [recentVulns, setRecentVulns] = useState<Vulnerability[]>([]);
|
||||
const [aiPriorities, setAiPriorities] = useState<AIPriorityResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Separate from `loading`: the page unblocks on the stat cards, and the
|
||||
// widgets keep their own spinner until the rest of the batch is in.
|
||||
const [widgetsLoading, setWidgetsLoading] = useState(true);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
// AI audit hits an editor-gated endpoint → hide the controls from read-only.
|
||||
@@ -253,8 +269,16 @@ export default function Dashboard() {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// The stat cards need this one and nothing else, so it lifts the
|
||||
// page's own gate as soon as it lands. Before, the whole dashboard sat
|
||||
// behind `Promise.all` of thirteen requests and showed a bare
|
||||
// "Loading Dashboard..." until the SLOWEST of them answered — the
|
||||
// numbers were ready long before the page admitted it.
|
||||
const statsP = api.get('/api/v1/vulnerabilities/reports/dashboard');
|
||||
statsP.then((r) => { setStats(r.data); setLoading(false); }).catch(() => { });
|
||||
|
||||
const [statsRes, vulnsRes, criticalRes, eolRes, mobileRes, kevRes, kevMineRes, schedRes, compRes, ursRes] = await Promise.all([
|
||||
api.get('/api/v1/vulnerabilities/reports/dashboard'),
|
||||
statsP,
|
||||
// Newly Published: sort by published_date desc. distinct_cve=true
|
||||
// collapses per-asset duplicates server-side so we reliably get 10
|
||||
// distinct CVEs (client dedup alone starved when a CVE hit N assets).
|
||||
@@ -417,6 +441,7 @@ export default function Dashboard() {
|
||||
console.error("Failed to fetch dashboard data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setWidgetsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -424,7 +449,9 @@ export default function Dashboard() {
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center">Loading Dashboard...</div>;
|
||||
return (
|
||||
<PageLoader label="Loading dashboard…" />
|
||||
);
|
||||
}
|
||||
|
||||
const statItems = [
|
||||
@@ -437,6 +464,7 @@ export default function Dashboard() {
|
||||
// Full available width (minus AppShell padding) — the empty left/right
|
||||
// gutters should be used on wide monitors. No max-width cap.
|
||||
<div className="w-full">
|
||||
<SyncHealthBanner />
|
||||
{/* Header Section */}
|
||||
<div className="md:flex md:items-center md:justify-between mb-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -467,7 +495,6 @@ export default function Dashboard() {
|
||||
onClick={cancelAIAudit}
|
||||
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-red-300 bg-red-50 text-red-700 hover:bg-red-100 transition-all"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
Cancel AI Audit
|
||||
</button>
|
||||
) : (
|
||||
@@ -475,7 +502,6 @@ export default function Dashboard() {
|
||||
onClick={() => fetchAIRecommendations()}
|
||||
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-indigo-50 hover:ring-indigo-300 transition-all"
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4 mr-2 text-indigo-600" />
|
||||
AI Audit
|
||||
</button>
|
||||
)}
|
||||
@@ -822,6 +848,7 @@ export default function Dashboard() {
|
||||
title: 'Recent Critical CVEs',
|
||||
subtitle: 'CVSS ≥ 8 or Critical or KEV or EUVD · newest first, ties by CPR',
|
||||
vulns: criticalVulns,
|
||||
loading: widgetsLoading,
|
||||
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
||||
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
|
||||
})}
|
||||
@@ -829,6 +856,7 @@ export default function Dashboard() {
|
||||
title: 'Newly Published CVEs',
|
||||
subtitle: 'All severities · sorted by CVE published date',
|
||||
vulns: recentVulns,
|
||||
loading: widgetsLoading,
|
||||
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
|
||||
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
|
||||
})}
|
||||
@@ -836,6 +864,7 @@ export default function Dashboard() {
|
||||
title: 'Newly EOL / EOS',
|
||||
subtitle: 'End-of-life software · sorted by detection',
|
||||
vulns: eolVulns,
|
||||
loading: widgetsLoading,
|
||||
labelField: 'package_name',
|
||||
firstColHeader: 'Product',
|
||||
assetColumn: true,
|
||||
@@ -847,6 +876,7 @@ export default function Dashboard() {
|
||||
title: 'Mobile Security · EOL & Patch Level',
|
||||
subtitle: 'Phones & tablets · vendor EOL/EOS + Android patch staleness',
|
||||
vulns: mobileVulns,
|
||||
loading: widgetsLoading,
|
||||
labelField: 'package_name',
|
||||
firstColHeader: 'Device / Item',
|
||||
assetColumn: true,
|
||||
@@ -865,15 +895,16 @@ export default function Dashboard() {
|
||||
wherever its data is shown — so the sources come from the
|
||||
same response as the rows. */}
|
||||
<p className="text-[11px] text-gray-500 font-mono mt-0.5">
|
||||
{(kevSourceLabels.length ? kevSourceLabels : ['CISA KEV', 'ENISA EUVD']).join(' · ')} · 🔒 = Ransomware use
|
||||
{(kevSourceLabels.length ? kevSourceLabels : ['CISA KEV', 'ENISA EUVD']).join(' · ')} · RANSOM = known ransomware use
|
||||
</p>
|
||||
</div>
|
||||
<a href="/advisories"
|
||||
<a href="/advisories/kev"
|
||||
className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All ></a>
|
||||
</div>
|
||||
<div className="overflow-x-auto flex-1">
|
||||
{kevAdvisories.length === 0 ? (
|
||||
<p className="p-4 text-xs text-gray-400 font-mono">No KEV data.</p>
|
||||
<p className="p-4 text-xs text-gray-400 font-mono">
|
||||
{widgetsLoading ? 'Loading…' : 'No KEV data.'}</p>
|
||||
) : (
|
||||
<table className="min-w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
@@ -882,7 +913,12 @@ export default function Dashboard() {
|
||||
onClick={() => router.push(`/vulnerabilities?cve_id=${k.cve_id}`)}>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<span className="font-mono text-truevuln-blue text-xs font-bold">{k.cve_id}</span>
|
||||
{k.ransomware && <span title="Known Ransomware campaign use" className="ml-1">🔒</span>}
|
||||
{k.ransomware && (
|
||||
<span title="Known Ransomware campaign use"
|
||||
className="ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono bg-red-50 text-red-700">
|
||||
RANSOM
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 truncate max-w-[180px]" title={`${k.vendor || ''} ${k.product || ''}`}>
|
||||
{[k.vendor, k.product].filter(Boolean).join(' · ') || k.name || ''}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
import { PageLoader } from "@/components/ui/Loading";
|
||||
|
||||
// Patch Plan — the findings list grouped into fix actions: "update X" with
|
||||
// what it removes. Ranked by the backend (patch_plan_service): exploited in
|
||||
// the wild (KEV / EUVD) first, then summed priority score. The Jev column is
|
||||
// a hint to check the setup, never part of the rank.
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../lib/api';
|
||||
import Pagination from '../../components/shared/Pagination';
|
||||
|
||||
type Group = {
|
||||
product: string;
|
||||
hosts: number;
|
||||
findings: number;
|
||||
cve_count: number;
|
||||
fixed_versions: string[];
|
||||
exploited_cves: string[];
|
||||
config_dependent_cves: string[];
|
||||
priority_sum: number;
|
||||
priority_max: number;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
// Sort value per column; list columns sort by their length.
|
||||
const sortValue = (g: Group, col: string): string | number => {
|
||||
if (col === 'exploited') return g.exploited_cves.length;
|
||||
if (col === 'config') return g.config_dependent_cves.length;
|
||||
if (col === 'fixed') return g.fixed_versions.join(', ');
|
||||
return g[col as 'product' | 'hosts' | 'findings' | 'cve_count' | 'priority_sum' | 'rank'];
|
||||
};
|
||||
|
||||
export default function PatchPlanPage() {
|
||||
const [plan, setPlan] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
// Default = the backend's rank (exploited first, then summed priority).
|
||||
const [sortBy, setSortBy] = useState('rank');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
// The backend returns the whole plan; paging and sorting are local.
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/api/v1/vulnerabilities/patch-plan')
|
||||
.then((r) => setPlan((r.data || []).map((g: Group, i: number) => ({ ...g, rank: i + 1 }))))
|
||||
// detail is an object list on a 422 (an older backend routes
|
||||
// /patch-plan to /{vuln_id}) — rendering it would blank the page.
|
||||
.catch((e) => {
|
||||
const d = e?.response?.data?.detail;
|
||||
setError(typeof d === 'string' ? d
|
||||
: `Could not load the patch plan (HTTP ${e?.response?.status ?? '?'}). Is the backend up to date?`);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Whole plan, not the page — same as the assets export.
|
||||
const handleExportCsv = async () => {
|
||||
setError('');
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const response = await api.get('/api/v1/vulnerabilities/patch-plan/export', { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const disposition = response.headers['content-disposition'] as string | undefined;
|
||||
link.setAttribute('download', disposition?.match(/filename="?([^";]+)"?/)?.[1] || 'patch_plan.csv');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('Could not export the patch plan as CSV.');
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
setPage(1);
|
||||
if (sortBy === column) {
|
||||
setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'));
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortArrow = (column: string) => {
|
||||
if (sortBy !== column) {
|
||||
return <span className="text-gray-300 ml-1">↕</span>;
|
||||
}
|
||||
return (
|
||||
<span className="text-truevuln-blue ml-1">
|
||||
{sortOrder === 'desc' ? '↓' : '↑'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const sorted = [...plan].sort((a, b) => {
|
||||
const x = sortValue(a, sortBy), y = sortValue(b, sortBy);
|
||||
const c = typeof x === 'number' && typeof y === 'number'
|
||||
? x - y : String(x).localeCompare(String(y), undefined, { numeric: true });
|
||||
return sortOrder === 'asc' ? c : -c;
|
||||
});
|
||||
const shown = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
if (loading) return <PageLoader label="Loading patch plan…" />;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 font-mono">Patch Plan</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-gray-500">
|
||||
Open findings on active assets, grouped by the product to update. Exploited in the wild
|
||||
(CISA KEV / ENISA EUVD) first, then by the summed priority score the update removes.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCsv}
|
||||
disabled={exportLoading || plan.length === 0}
|
||||
className="tv-btn whitespace-nowrap"
|
||||
title="Export the whole plan as CSV — ignores pagination"
|
||||
>
|
||||
{exportLoading ? 'Exporting…' : 'Export CSV'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 font-mono mb-4">{error}</p>}
|
||||
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50 text-xs font-mono uppercase text-gray-500">
|
||||
<tr>
|
||||
<th onClick={() => handleSort('rank')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700" title="Recommended order: exploited first, then summed priority">#{sortArrow('rank')}</th>
|
||||
<th onClick={() => handleSort('product')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700">Update{sortArrow('product')}</th>
|
||||
<th onClick={() => handleSort('fixed')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700">Fixed in{sortArrow('fixed')}</th>
|
||||
<th onClick={() => handleSort('hosts')} className="px-3 py-2 text-right cursor-pointer select-none hover:text-gray-700">Hosts{sortArrow('hosts')}</th>
|
||||
<th onClick={() => handleSort('findings')} className="px-3 py-2 text-right cursor-pointer select-none hover:text-gray-700">Findings{sortArrow('findings')}</th>
|
||||
<th onClick={() => handleSort('cve_count')} className="px-3 py-2 text-right cursor-pointer select-none hover:text-gray-700">CVEs{sortArrow('cve_count')}</th>
|
||||
<th onClick={() => handleSort('exploited')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700">Exploited{sortArrow('exploited')}</th>
|
||||
<th onClick={() => handleSort('priority_sum')} className="px-3 py-2 text-right cursor-pointer select-none hover:text-gray-700" title="Summed / highest priority score">Priority Σ / max{sortArrow('priority_sum')}</th>
|
||||
<th onClick={() => handleSort('config')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700" title="Jev: CVE text suggests it needs an optional feature or non-default configuration. Check your setup; the rank is unaffected.">Check config{sortArrow('config')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 font-mono">
|
||||
{plan.length === 0 && !error && (
|
||||
<tr><td colSpan={9} className="px-3 py-4 text-gray-400">No open findings on active assets.</td></tr>
|
||||
)}
|
||||
{shown.map((g) => (
|
||||
<tr key={g.product} className={g.exploited_cves.length ? 'bg-red-50/40' : ''}>
|
||||
<td className="px-3 py-2 text-gray-400">{g.rank}</td>
|
||||
<td className="px-3 py-2">
|
||||
<a href={`/vulnerabilities?search=${encodeURIComponent(`"${g.product}"`)}`}
|
||||
className="font-semibold text-truevuln-blue hover:underline">{g.product}</a>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600" title={g.fixed_versions.join(', ')}>
|
||||
{g.fixed_versions.slice(0, 3).join(', ') || '—'}
|
||||
{g.fixed_versions.length > 3 && ` +${g.fixed_versions.length - 3}`}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{g.hosts}</td>
|
||||
<td className="px-3 py-2 text-right">{g.findings}</td>
|
||||
<td className="px-3 py-2 text-right">{g.cve_count}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{g.exploited_cves.length > 0 && (
|
||||
<span className="rounded px-1.5 py-0.5 bg-red-100 text-red-700 font-bold"
|
||||
title={g.exploited_cves.join(', ')}>
|
||||
{g.exploited_cves.length} KEV/EUVD
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{g.priority_sum} <span className="text-gray-400">/ {g.priority_max}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{g.config_dependent_cves.length > 0 && (
|
||||
<span className="rounded px-1.5 py-0.5 bg-amber-100 text-amber-800"
|
||||
title={g.config_dependent_cves.join(', ')}>
|
||||
{g.config_dependent_cves.length} CVE
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination page={page} pageSize={pageSize} total={plan.length}
|
||||
onPageChange={setPage} onPageSizeChange={(n) => { setPageSize(n); setPage(1); }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { dialog } from "@/components/ui/dialog";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
ClockIcon,
|
||||
PencilIcon,
|
||||
@@ -43,6 +45,7 @@ export default function PoliciesPage() {
|
||||
const [editingPolicy, setEditingPolicy] = useState<Partial<Policy>>(DEFAULT_POLICY);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [userRole, setUserRole] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
// Create + Delete are RequireAdmin server-side, Edit is RequireEditor.
|
||||
// The nav lets editors onto this page, so without these two gates an
|
||||
@@ -59,6 +62,8 @@ export default function PoliciesPage() {
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch policies", error);
|
||||
// Policies is editor+; the sidebar hides it, the URL does not.
|
||||
if ((error as { response?: { status?: number } })?.response?.status === 403) router.push('/');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -93,12 +98,12 @@ export default function PoliciesPage() {
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save policy", error);
|
||||
alert("Failed to save policy");
|
||||
dialog.alert("Failed to save policy");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("Are you sure you want to delete this policy? Assets assigned to it will fallback to defaults.")) return;
|
||||
if (!await dialog.confirm("Are you sure you want to delete this policy? Assets assigned to it will fallback to defaults.", { tone: "danger", confirmLabel: "Delete policy" })) return;
|
||||
try {
|
||||
await api.delete(`/api/v1/policies/${id}`);
|
||||
fetchPolicies();
|
||||
@@ -119,7 +124,6 @@ export default function PoliciesPage() {
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-truevuln-blue text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Create Policy
|
||||
</button>
|
||||
)}
|
||||
@@ -197,6 +201,7 @@ export default function PoliciesPage() {
|
||||
<div className="text-2xl font-black text-gray-900">{policy.compliance}%</div>
|
||||
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Compliance</div>
|
||||
</div>
|
||||
{(canEdit || isAdmin) && (
|
||||
<div className="flex gap-2">
|
||||
{canEdit && (
|
||||
<button
|
||||
@@ -217,6 +222,7 @@ export default function PoliciesPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { DocumentTextIcon, ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
||||
import { dialog } from "@/components/ui/dialog";
|
||||
|
||||
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||
import api from '../../lib/api';
|
||||
|
||||
const reports = [
|
||||
@@ -48,7 +50,7 @@ export default function ReportsPage() {
|
||||
link.remove();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Failed to download report. Please ensure you are logged in.");
|
||||
dialog.alert("Failed to download report. Please ensure you are logged in.");
|
||||
}
|
||||
} else if (report.id === 1) {
|
||||
try {
|
||||
@@ -62,7 +64,7 @@ export default function ReportsPage() {
|
||||
link.remove();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Failed to generate PDF report.");
|
||||
dialog.alert("Failed to generate PDF report.");
|
||||
}
|
||||
} else if (report.id === 3) {
|
||||
try {
|
||||
@@ -76,7 +78,7 @@ export default function ReportsPage() {
|
||||
link.remove();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Failed to generate Compliance report.");
|
||||
dialog.alert("Failed to generate Compliance report.");
|
||||
}
|
||||
} else if (report.id === 4) {
|
||||
try {
|
||||
@@ -90,10 +92,10 @@ export default function ReportsPage() {
|
||||
link.remove();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Failed to generate Patching Progress report.");
|
||||
dialog.alert("Failed to generate Patching Progress report.");
|
||||
}
|
||||
} else {
|
||||
alert(`Report generation for ${report.name} is coming soon!`);
|
||||
dialog.alert(`Report generation for ${report.name} is coming soon!`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,7 +129,6 @@ export default function ReportsPage() {
|
||||
onClick={() => handleDownload(report)}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-truevuln-blue hover:text-blue-700"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-4 w-4" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user