feat(netdisco): switch estate as assets, with Aruba firmware CVEs

A switch runs no agent, no MDM enrols it, and a Nessus scan without SNMP
credentials sees an open port and no version — so the devices everything
else is plugged into had no coverage at all. Netdisco already asks them
over SNMP; this reads its deviceinventory report and turns it into assets
and findings.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 11:44:19 +02:00
co-authored by Claude Opus 5
parent ea064107fd
commit bcaa4f02ee
16 changed files with 2085 additions and 14 deletions
+197 -1
View File
@@ -1739,7 +1739,7 @@ python -m app.services.vmware_release_service
# Sync run ledger — what counts as a failed run
Every sync execution (Wazuh, Nessus, Intune, vCenter, IGEL; button or
Every sync execution (Wazuh, Nessus, Intune, vCenter, IGEL, Netdisco; button or
scheduler) is one `sync_runs` row, written by `record_sync_run()` in
`app/services/sync_run_service.py` from its own session. The Scan Jobs page
shows them as **Sync Health** (per source) and **Sync Runs** (the ledger); the
@@ -1834,3 +1834,199 @@ The indexer-side incident is replayed by three more: every agent refused
(`failed`, `agents_synced=0`, `502` at the button, one mail), one agent
refused while another answers (the refused host keeps its findings), and an
indexer that answers nothing for everybody (`failed` via `outage`).
---
# Netdisco — switch/router inventory, HPE Aruba firmware CVEs
A switch runs no agent. Wazuh cannot reach it, no MDM enrols it, and a Nessus
scan without SNMP credentials sees an open SSH port and no version — so the
devices every other asset is plugged into had no vulnerability coverage at
all. Netdisco already asks each of them over SNMP and records the firmware;
this turns that inventory into assets and findings.
## What it adds
| Piece | File |
|---|---|
| Netdisco REST client (`/api/v1`) | `app/integrations/netdisco_client.py` |
| Inventory sync → assets + CVE pass | `app/services/netdisco_service.py` |
| Aruba family / version / branch logic | `cvelistv5_scan_service.aruba_*` |
| CVE matching pass (CNA records) | `cvelistv5_scan_service.scan_asset_aruba` |
| CVE matching pass (NVD CPE) | `app_cve_scanner_service._OS_REGISTRY` (3 entries) |
| HTTP endpoints | `app/routers/netdisco.py` |
| Schema | Alembic `052``AssetSource.NETDISCO`, `assets.netdisco_device_ip` |
The sync registers **every device Netdisco has discovered** as an asset
(`source=NETDISCO`) with hostname, canonical IP, location, model, serial and
firmware version. CVEs are detected for the three HPE Aruba firmware lines;
other vendors get the asset and no CVE verdict (see *Known scope*).
## Authentication and transport
One endpoint is read: `GET /api/v1/report/device/deviceinventory`. Three auth
modes, all the same mechanism seen from different ends:
- **API key** — `Authorization: <key>` (Netdisco's spelling: no `Bearer`).
What a production instance should use.
- **Username + password** — POSTed once to `/login`, which *returns* exactly
such a key. So "key or credentials" is not two code paths.
- **Neither** — the public demo answers the report unauthenticated. Supported
so the connector can be tested before it is pointed at production.
`http` **and** `https` are both configurable, because Netdisco's own web
server (the Docker image included) speaks plain HTTP on port 5000 and cannot
do TLS at all; a production deployment fronts it with a reverse proxy. Hence
port 5000 + HTTP as the default, and `verify_ssl` as a separate switch for a
proxy carrying an internal CA's certificate.
## The version is the point, and the letters are not part of it
HPE prefixes a firmware 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. HPE's own advisories bound the
numbers only ("KB/WC/YA/YB/YC.16.11.0015 and below"), so `aruba_version()`
keeps the numeric run and drops the rest. That is also what makes the version
usable at all: `_clean_version` rejects anything not dotted-numeric, so
`WC.16.11.0016` would otherwise mean *no version*, which means *no scan*.
The raw string stays on the asset description — it is what an operator matches
against HPE's download page.
## Three families, and why they must never cross
| Asset OS label | Firmware | cvelistV5 product | NVD CPE |
|---|---|---|---|
| `ArubaOS-CX` | `10.13.1005` | `AOS-CX` | `cpe:2.3:o:hpe:arubaos-cx` |
| `ArubaOS-Switch` | `16.11.0016` | `ArubaOS-Switch`, `ArubaOS-S Switch` | `cpe:2.3:o:hpe:arubaos-switch` |
| `ArubaOS` (Mobility) | `8.13.1.1` / `10.7.2.2` | `HPE Aruba Networking Wireless Operating System (AOS)` | `cpe:2.3:o:arubanetworks:arubaos` (+ `hp:arubaos`) |
A CX switch on 10.13.1005 and a Mobility controller on 10.7.2.2 are both
"10.x" and share nothing else. `netdisco_service.aruba_family()` therefore
requires **three** things to agree before it claims a family — the SNMP::Info
os slug, the vendor, and the shape of the version:
- `arubaos-cx` + three components → CX. SNMP::Info's `Layer3::ArubaCX`.
- `hp` (that is what a ProVision switch answers) + an Aruba vendor + a
**15.x/16.x** major → AOS-S. The major check is what keeps a non-switch HP
device, which answers the same slug with some other numbering, out.
- `aos-w`/`airos` + an Aruba vendor + **four** components → Mobility AOS.
Ubiquiti's AirOS answers the same slug; the vendor is what separates them.
Anything else gets an asset and no verdict. The asset's OS string is written
as one of the three canonical labels, never as the device's own answer — a
Nessus- or hand-created asset for the same box would spell it a third way.
At scan time a second guard applies: an index entry that states **no floor**
must have the same release **branch** (major.minor) as the installed version.
HPE writes one range per branch on the same record (CVE-2026-73749 carries
10.18, 10.17, 10.16, 10.13 and 10.10 side by side), so a bound read across
branches would tell a 10.13 switch to install an image its hardware may not
take. Entries that do carry a floor are already confined by it.
## ArubaOS-Switch is stated as prose
AOS-CX and the controllers come as proper semver ranges. The switch records do
not — every branch is a sentence inside the `version` field:
```
"ArubaOS-Switch 16.11.xxxx: KB/WC/YA/YB/YC.16.11.0015 and below"
"ArubaOS-Switch 16.09.xxxx: All versions."
```
`aruba_switch_entries()` parses the first shape (branch as the floor, the
number as an inclusive bound) and deliberately skips the second: "All
versions" has no bound, so it can neither be compared nor cleared by an
upgrade inside that branch — a finding with no fix to reach is the noise this
scanner exists to avoid. The cost is under-reporting on an abandoned branch,
never a wrong verdict. The two-letter code lines in the prose are ignored;
HPE ships one build number across all of them, and the one record that splits
them states the other lines as "All versions" anyway.
Handing the prose to the shared extractor would be worse than useless: read as
digits, that sentence parses to `(16,11,16,11,12)`. Both the index build and
the scan reject a bound that is not plain dotted-numeric.
## Configuration
`netdisco_config` (encrypted at rest, secrets redacted on GET):
```json
{"host":"netdisco.local","port":5000,"use_https":false,"verify_ssl":true,
"api_key":"…","username":"","password":"","auto_create_assets":true}
```
Only `host` is required. Settings card: **Netdisco (switches & routers)**.
## Endpoints
```
POST /api/v1/integrations/netdisco/test admin — reachability + auth
POST /api/v1/integrations/netdisco/sync editor — 202, runs in a thread
GET /api/v1/integrations/netdisco/sync/status editor — poll target
```
Nightly at **02:40 UTC** (`netdisco_sync_nightly`), after the IGEL sync and
before the 03:20 app-CVE scan; the cvelistV5 index it reads is rebuilt at
01:30. The app-CVE scan runs `scan_asset_aruba` over every asset too, so a
device also known to another source is not left to the connector alone.
## Asset matching + lifecycle
Pinned on **Netdisco's canonical device IP** (`assets.netdisco_device_ip`),
then hostname, then IP. IP first is the opposite of the IGEL rule and for the
opposite reason: a switch's management address is static and its name is what
changes, while a thin client holds a DHCP lease that can be recycled onto
another device.
`reconcile_netdisco_by_seen_ids` inactivates only devices Netdisco no longer
lists at all — an unreachable switch stays in Netdisco's inventory, so a
reboot or a maintenance window never reaches the reconcile. Fail-open on an
empty sync.
## Known scope
- **HPE Aruba only.** Cisco, Cumulus and the rest are inventoried without a
CVE verdict; each needs its own curated matching, and a generic version
comparison across vendors is how a scanner produces a page of wrong
findings.
- **No vendor bulletins.** HPE's own (hpesbnw*) carry the same data the CNA
records already state structurally, so a scraper would add an opinion and no
CVE. Add one if an advisory ever turns out to exist nowhere else.
- **No EOL/EOM findings.** End-of-maintenance is the finding that matters most
for a switch — a firmware line that no longer gets security fixes cannot be
patched out of anything — but HPE publishes those dates per *hardware
product* in a support portal, and endoflife.date carries no ArubaOS product
at all. Nothing is claimed until a source that states EOM per OS release is
found; an invented date would retire real assets on a guess.
- A device without a version gets an asset and no verdict (counted as
`no_version` in the sync stats — "could not look" must not read as "clean").
- **Instant APs share the Mobility version line.** An Aruba Instant AP answers
the same os slug with the same four-component 8.x/10.x numbering as a
controller, so it is read as `ArubaOS` — which is broadly right (HPE ships
one 8.x codebase and its advisories name both), but a controller-only
advisory can reach an AP this way. Split it out when a record is found that
bounds them differently; NVD keeps a separate `arubanetworks:instant` CPE for
that day.
## Verification
```bash
# 1. Migrate
docker compose exec backend alembic upgrade head # expect 052
# 2. Configure netdisco_config in the Settings UI, then Test connection.
# Expect: {"ok":true,"device_count":N,"aruba_count":M,"auth":"api-key"}
# 3. First sync
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/v1/integrations/netdisco/sync
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/v1/integrations/netdisco/sync/status
# → {devices, assets_matched, assets_created, aruba_devices, no_version,
# cve_findings, assets_inactivated, assets_reactivated}
# 4. Offline check of the matching rules (no Netdisco needed)
venv/bin/python tests/test_aruba_firmware.py
venv/bin/python tests/test_netdisco_inventory.py
```
@@ -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;")
+1
View File
@@ -32,6 +32,7 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
"intune_config",
"vcenter_config",
"igel_config",
"netdisco_config",
"github_pat",
})
+200
View File
@@ -0,0 +1,200 @@
"""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 aruba_family
aruba = sum(1 for d in devices
if aruba_family(d["os"], d["vendor"], d["os_version"]))
return {"ok": True, "device_count": len(devices), "aruba_count": aruba,
"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]
+2 -1
View File
@@ -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, advisories
try:
from app.routers import auth_oidc
_HAS_OIDC = True
@@ -333,6 +333,7 @@ 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(advisories.router)
app.include_router(assets.router)
app.include_router(policies.router)
+7 -1
View File
@@ -23,6 +23,7 @@ class AssetSource(str, Enum):
INTUNE = "INTUNE"
VCENTER = "VCENTER"
IGEL = "IGEL"
NETDISCO = "NETDISCO"
class AssetStatus(str, Enum):
@@ -76,6 +77,11 @@ class Asset(Base, TimestampMixin):
# 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)
# 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)
# System-Information
operating_system = Column(String(255), nullable=True)
@@ -121,7 +127,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
+104
View File
@@ -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")
async 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)
async 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")
async 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"]}
+38 -2
View File
@@ -695,6 +695,31 @@ def igel_sync_nightly():
db.close()
def netdisco_sync_nightly():
"""Sync Netdisco's device inventory → assets and Aruba 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 m365_check_nightly():
"""Detect Microsoft 365 Apps CVEs (Plan P) for every Wazuh-linked asset.
@@ -763,7 +788,8 @@ def vuln_index_refresh_nightly():
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 — and
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
@@ -775,6 +801,7 @@ def vuln_index_refresh_nightly():
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
@@ -846,7 +873,7 @@ def app_cve_scan_nightly():
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 and IGEL syncs that read them too; run_app_cve_scan builds the
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.
"""
@@ -1423,6 +1450,15 @@ 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 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.
+57 -4
View File
@@ -302,10 +302,41 @@ _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"}),
(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,7 +447,11 @@ 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"
@@ -1283,7 +1318,8 @@ def scan_asset_os(db: Session, asset, new_ids: list, touched: Optional[set] = No
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")}, cver)
except Exception as ex:
logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
return 0
@@ -1600,6 +1636,23 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None,
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}")
# Package-level CVEs (Wazuh syscollector / Intune detectedApps).
#
# `inventory_complete` decides whether this run is allowed to CLOSE
+71
View File
@@ -716,3 +716,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
+286 -1
View File
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
_ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
_ZIP_TTL = 12 * 3600
_INDEX_SETTING = "cvelistv5_product_index_v31" # v31: IGEL OS
_INDEX_SETTING = "cvelistv5_product_index_v32" # v32: HPE Aruba (AOS-CX/AOS-S/AOS)
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
@@ -475,6 +475,48 @@ _PRODUCT_PATTERNS: List[dict] = [
{"key": "igel-os", "vendor_lit": "igel",
"vendor_re": r"^igel(\s+technology(\s+gmbh)?)?$",
"product_re": r"^(igel\s+)?(os|linux)(\s+\d+)?$"},
# HPE Aruba networking gear, as inventoried by the Netdisco connector.
# THREE products, three version lines, and keeping them apart is the whole
# safety story — HPE files all of them under one vendor and the numbers
# overlap:
#
# AOS-CX 10.13.1005 campus/data-centre switches (6300, 6200, ...)
# ArubaOS-Switch 16.11.0016 the ProVision line (2530, 2930F, 5400R)
# ArubaOS (AOS) 8.13.1.1 / 10.7.2.2 Mobility controllers and gateways
#
# A CX switch on 10.13.1005 and a Mobility controller on 10.7.2.2 are both
# "10.x" and share nothing else: matched across, the switch would be told
# to install a controller image. So each family gets its OWN key, its own
# anchored product regex, and the scan additionally checks the release
# BRANCH (see scan_asset_aruba).
#
# Product spellings are the ones HPE's CNA actually writes, verified
# against the records: "AOS-CX" (CVE-2026-73749, -44880), "ArubaOS-Switch"
# (CVE-2023-39266) and "ArubaOS-S Switch" (CVE-2024-26303), and "HPE Aruba
# Networking Wireless Operating System (AOS)" (CVE-2026-44857). The vendor
# is "Hewlett Packard Enterprise (HPE)" today and "Aruba Networks" on the
# older records.
#
# The anchors matter as much as they do for IGEL: HPE files ClearPass,
# AirWave, Central, EdgeConnect, InstantOS and the Fabric Composer under
# the same vendor, each with a version line that would compare
# nonsensically against a switch's firmware. None of them can pass.
{"key": "aruba-cx", "vendor_lit": "hewlett packard enterprise",
"vendor_re": r"^(hewlett[- ]packard enterprise( \(hpe\))?|hpe|hp|"
r"aruba ?networks?( ?, ?inc\.?)?|n/a)$",
"product_re": r"^(hpe\s+)?(aruba\s+networking\s+)?(arubaos-cx|aos-cx)"
r"(\s+switch(es)?)?$"},
{"key": "aruba-switch", "vendor_lit": "aruba networks",
"vendor_re": r"^(hewlett[- ]packard enterprise( \(hpe\))?|hpe|hp|"
r"aruba ?networks?( ?, ?inc\.?)?|n/a)$",
"product_re": r"^(hpe\s+)?(aruba\s+networking\s+)?(arubaos-s(witch)?|aos-s)"
r"(\s+switch(es)?)?$"},
{"key": "aruba-os", "vendor_lit": "arubaos",
"vendor_re": r"^(hewlett[- ]packard enterprise( \(hpe\))?|hpe|hp|"
r"aruba ?networks?( ?, ?inc\.?)?|alcatel-lucent|n/a)$",
"product_re": r"^(arubaos|aruba\s+os|aos-w|"
r"(hpe\s+)?aruba\s+networking\s+wireless\s+operating\s+"
r"system\s+\(aos\))$"},
]
# The vSphere keys. Their bounds are build numbers and release names, not
@@ -1101,6 +1143,15 @@ def build_product_index(db: Session, force_fresh: bool = False) -> dict:
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod,
"desc": desc})
continue
if key == "aruba-switch":
for ent in aruba_switch_entries(aff, cve_id, cvss, sev, desc):
_sig = (key, cve_id, ent.get("start"), ent.get("lt"),
ent.get("lte"), prod.lower())
if _sig in seen:
continue
seen.add(_sig)
index.setdefault(key, []).append(ent)
continue
fix = igel_fix_version(aff) if key == "igel-os" else None
for start, lt, lte in _ranges_from_affected(aff):
sig = (key, cve_id, start, lt, lte, prod.lower())
@@ -1902,6 +1953,240 @@ def scan_asset_igel(db: Session, asset, index: dict,
return count
# ---------- HPE Aruba switches / controllers (Netdisco inventory) ----------
#
# A switch is the IGEL problem again, one layer down: it runs no agent, no MDM
# enrols it, and a network scan sees an SSH port and no version. Netdisco does
# see the firmware — it asks the device over SNMP — so the Netdisco connector
# writes the family onto the asset as its OS and the numeric firmware as its
# version, and this is what turns that into findings.
#
# The asset's OS string decides the family. It is written by
# netdisco_service.aruba_family() as one of three canonical labels, never as
# whatever the device answered: SNMP::Info reports "hp" for a ProVision switch
# and "arubaos-cx" for a CX one, and a Nessus- or hand-created asset for the
# same box would spell it a third way.
ARUBA_CX_LABEL = "ArubaOS-CX"
ARUBA_SWITCH_LABEL = "ArubaOS-Switch"
ARUBA_OS_LABEL = "ArubaOS"
ARUBA_LABELS = {"aruba-cx": ARUBA_CX_LABEL,
"aruba-switch": ARUBA_SWITCH_LABEL,
"aruba-os": ARUBA_OS_LABEL}
# Ordered: the CX and Switch patterns must be tried before the bare ArubaOS
# one, which would otherwise swallow both ("arubaos-cx" starts with "arubaos").
_ARUBA_OS_RES = [
(re.compile(r"^\s*(hpe\s+)?(aruba\s*)?(arubaos|aos)[-\s]?cx\b", re.I), "aruba-cx"),
(re.compile(r"^\s*(hpe\s+)?(aruba\s*)?(arubaos|aos)[-\s]?s(witch)?\b", re.I),
"aruba-switch"),
(re.compile(r"^\s*(hpe\s+)?aruba\s*os\b|^\s*arubaos\b|^\s*aos-w\b", re.I),
"aruba-os"),
]
# The numeric firmware version inside an Aruba version string. HPE prefixes the
# build with a two-letter CODE LINE — "WC.16.11.0016" on a 2930F, "YA.16.11.0027"
# on a 2530, "PL.10.13.1005" on a CX 6300 — which names the hardware family the
# image is for, not the version. HPE's own advisories bound the numbers only
# ("KB/WC/YA/YB/YC.16.11.0015 and below"), so the letters are dropped here and
# every comparison is numeric. Dropping them is also what makes the version
# usable at all: cpe._clean_version rejects anything that is not dotted-numeric,
# so "WC.16.11.0016" would otherwise mean "no version", which means no scan.
_ARUBA_VER_RE = re.compile(r"\d+(?:\.\d+){1,3}")
def aruba_version(raw: Optional[str]) -> Optional[str]:
""""WC.16.11.0016""16.11.0016". None when there is no version in there.
Also survives the longer strings other sources report for the same box
("ArubaOS (MODEL: Aruba7005), Version 8.6.0.7"): the first dotted-numeric
run IS the version, because a model number carries no dot.
"""
m = _ARUBA_VER_RE.search((raw or "").strip())
return m.group(0) if m else None
def aruba_key(os_name: Optional[str]) -> Optional[str]:
"""Canonical Aruba OS label → curated product key, or None."""
n = (os_name or "").strip()
if not n:
return None
for rx, key in _ARUBA_OS_RES:
if rx.match(n):
return key
return None
def aruba_branch(version: Optional[str]) -> Optional[tuple]:
"""Release branch of an Aruba version — major.minor, and nothing else.
HPE patches per branch and states one range per branch on the same record:
CVE-2026-73749 carries 10.18, 10.17, 10.16, 10.13 and 10.10 side by side,
each with its own last-affected build. A 10.13 switch that matched the
10.17 range would be told to install an image its hardware may not even
take the same class of error as sending an IGEL OS 11 device to 12.7.6.
"""
t = cpe._vtuple(version or "")
return t[:2] if t and len(t) >= 2 else None
# "ArubaOS-Switch 16.11.xxxx: KB/WC/YA/YB/YC.16.11.0015 and below" — the whole
# bound, written as a sentence. Anchored on the phrase, so a number that is not
# a bound (the "16.11.xxxx" branch heading, an advisory id) cannot be read as
# one.
_AOSS_PROSE_RE = re.compile(
r"(\d{1,2}\.\d{1,2}(?:\.\d{1,4}){1,2})\s*(?:and|or)\s+(?:below|earlier|lower|prior)",
re.I)
def aruba_switch_entries(aff: dict, cve_id: str, cvss=None, sev=None,
desc=None) -> List[dict]:
"""One ArubaOS-Switch affected[] block → index entries.
AOS-S is the one Aruba family whose records are not machine-readable. HPE
writes AOS-CX and the controllers as proper semver ranges (version +
lessThanOrEqual), but the switch records state every branch as PROSE inside
the `version` field:
"ArubaOS-Switch 16.11.xxxx: KB/WC/YA/YB/YC.16.11.0015 and below"
"ArubaOS-Switch 16.09.xxxx: All versions."
`_ranges_from_affected` finds no bound in either and drops them, which left
the ProVision switches the 2530/2930F estate this connector exists for
with only the three CVEs NVD happens to carry a CPE range for.
So: structured versions win when the record has them (HPE may start writing
them any day), and the prose is parsed only as a fallback.
"All versions" is deliberately NOT indexed. It has no bound, so it can
neither be compared nor ever be cleared by an upgrade inside that branch
the device has to leave the branch entirely, which is a lifecycle finding
and not a version verdict. Flagging it here would produce a finding with no
fix to reach, i.e. exactly the noise this scanner is built to avoid; the
cost is that a switch on such a branch is under-reported rather than
misreported.
The two-letter code lines in the prose are ignored, and that is a
deliberate, bounded imprecision: HPE ships one build number across all of
them ("KB/WC/YA/YB/YC.16.11.0015"), and the one record that splits them
(WB on its own cadence) states the OTHER lines as "All versions", which is
skipped anyway. A device's own code line is not carried on the asset, so
honouring the split would mean storing it for the sake of a case that
resolves the same way.
"""
prod = (aff.get("product") or "").strip()
meta = {"cve": cve_id, "plats": [], "cvss": cvss, "sev": sev,
"prod": prod, "desc": desc}
# The shared extractor is used ONLY when the block really carries bounds.
# Handed the prose, it reads the whole sentence as a version — "ArubaOS-
# Switch 16.11.xxxx: …0012 and below." parses to (16,11,16,11,12) — and
# emits it as a closed range, which is worse than no entry at all.
if any(isinstance(v, dict) and (v.get("lessThan") or v.get("lessThanOrEqual"))
for v in aff.get("versions") or []):
return [dict(meta, start=start, lt=lt, lte=lte)
for start, lt, lte in _ranges_from_affected(aff)]
out: List[dict] = []
for v in aff.get("versions") or []:
if not isinstance(v, dict) or (v.get("status") or "affected") != "affected":
continue
m = _AOSS_PROSE_RE.search(str(v.get("version") or ""))
if not m:
continue
bound = m.group(1)
t = cpe._vtuple(bound)
if not t or len(t) < 3:
continue
# The branch is the floor. Without it "16.10.0024 and below" would also
# cover every 16.04 and 15.16 switch, which the record states
# separately and differently — one entry per branch is exactly how HPE
# writes these.
out.append(dict(meta, start=f"{t[0]}.{t[1]}", lt=None, lte=bound))
return out
def scan_asset_aruba(db: Session, asset, index: dict,
new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""Aruba switch / controller CVEs from the firmware version on the asset.
Two guards, both needed:
1. FAMILY (aruba_key): decided from the asset's OS label, and each family
reads only its own curated key. AOS-CX 10.13.1005 and Mobility AOS
10.7.2.2 are both "10.x" of two unrelated products.
2. BRANCH (aruba_branch): for an entry that states no floor of its own, the
bound's major.minor must equal the installed one. HPE always writes one
range per branch, so an entry without a floor is a record that left it
implicit and read literally, "below 10.17.1021" swallows every 10.13
and 10.10 switch, all of which the same record bounds separately and
lower. Entries that DO carry a floor are already confined by it and are
left alone, so a genuinely cross-branch range still matches.
"""
if not index:
return 0
key = aruba_key(asset.operating_system or "")
if not key:
return 0
entries = index.get(key) or []
if not entries:
return 0
# The connector already stores the numeric form; aruba_version() is applied
# again for assets that came from somewhere else with the raw string on
# them (a Nessus scan, a hand-created row).
cver = cpe._clean_version(asset.os_version or "") or aruba_version(asset.os_version)
if not cver:
return 0
branch = aruba_branch(cver)
if branch is None:
return 0
label = ARUBA_LABELS[key]
if new_ids is None:
new_ids = []
count = 0
still_affected: set = set()
for entry in entries:
lt, lte = entry.get("lt"), entry.get("lte")
# Every bound has to be a plain dotted-numeric version. HPE states the
# switch records as prose, and a sentence read as digits is a bound
# nobody wrote — this is the backstop for one slipping through the
# index build (see aruba_switch_entries).
if cpe._clean_version(lt or lte or "") is None:
continue
if not entry.get("start") and aruba_branch(lt or lte) != branch:
continue
if not _affected(cver, entry.get("start"), lt, lte):
continue
still_affected.add(entry["cve"].upper())
# An inclusive bound names the last BROKEN build, not the fix — HPE
# writes "10.13.0000 .. 10.13.1180 affected" and the fixed image is the
# next one it published, which the record does not state. ">10.13.1180"
# is the honest answer and the same one the NVD path gives for
# versionEndIncluding; inventing a build number would not be.
fixed = lt or (f">{lte}" if lte else None)
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": fixed,
"desc": entry.get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, label, asset.os_version or cver, c, new_ids,
touched=touched, vendor="HPE Aruba Networking")
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("aruba upsert failed (%s on %s): %s",
entry["cve"], asset.id, e)
# A switch has no software inventory, so the app scan's own reconcile never
# reaches it — the same hole the vSphere and IGEL paths had. `touched` is
# folded in because the NVD-CPE pass runs first and sees CVEs this index
# cannot (AOS-S has three that exist only there); closing what the other
# half of the verdict just confirmed would flap them every night.
_resolve_stale_appliance(db, asset, label,
still_affected | set(touched or ()), cver,
tag="aruba")
return count
# ---------- Oracle Java ----------
_JAVA_NAME_RE = re.compile(r"\bjava\b[^\d]*(\d+)\s*update\s*(\d+)", re.I)
_JAVA_UPD_RE = re.compile(r"^(\d+)\s*u\s*(\d+)", re.I) # "8u491"
+394
View File
@@ -0,0 +1,394 @@
"""Netdisco inventory sync.
Registers every device Netdisco has discovered as an asset (source=NETDISCO),
then runs CVE detection on the HPE Aruba ones the same find-or-create +
lifecycle-reconcile pattern as the Intune, Nessus, vCenter and IGEL syncs.
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 it needs one 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.
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.
* Non-Aruba devices get an asset, an OS string and no CVE verdict. Cisco,
Cumulus 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.
"""
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 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
def os_and_version(d: dict) -> tuple:
"""Device row → (operating_system, os_version) to write onto the asset.
For an Aruba device that is the canonical family label and the NUMERIC
firmware version both CVE paths key off exactly those, and the raw
"WC.16.11.0016" is not a version any comparison can read.
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.
"""
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"))
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
def _find_or_create_asset(db: Session, d: dict, auto_create: bool):
"""Match a Netdisco device to an asset by canonical IP first, name second.
IP first because that is what Netdisco itself keys on: the canonical
address is a switch's identity there, it is configured statically on the
management interface, and it survives the renames that a switch does get
("sw-12" "sw-floor2-a"). This is the opposite of the IGEL rule, and
for the opposite reason a thin client takes a DHCP lease that can be
recycled onto another device, a switch does not.
"""
ip = (d.get("ip") or "").strip()
hostname = (d.get("name") or "").strip() or ip
short = hostname.split(".")[0] if hostname else ""
def _pin(a):
if ip and a.netdisco_device_ip != ip:
a.netdisco_device_ip = ip
if ip:
a = db.query(Asset).filter(Asset.netdisco_device_ip == ip).first()
if a:
_pin(a)
return a, "netdisco-ip"
for candidate in [c for c in (hostname, short) if c]:
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
if a:
_pin(a)
return a, "hostname"
if ip:
a = db.query(Asset).filter(Asset.ip_address == ip).first()
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, source=AssetSource.NETDISCO,
status=AssetStatus.ACTIVE)
db.add(a)
db.flush()
logger.info("Netdisco sync: auto-created asset %s", a.hostname)
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
auto_create = bool(cfg.get("auto_create_assets", True))
stats = {"devices": 0, "assets_matched": 0, "assets_created": 0,
"aruba_devices": 0, "no_version": 0, "cve_findings": 0,
"assets_inactivated": 0, "assets_reactivated": 0, "errors": []}
seen_asset_ids: set = set()
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 by the canonical IP means the name Netdisco reports
# now is the current one — switches get renamed.
name = (d.get("name") or "").strip()
if how == "netdisco-ip" and name and asset.hostname != name:
asset.hostname = (name.split(".")[0] or name)[:255]
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 c5.aruba_key(os_name or ""):
stats["aruba_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.
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 "",
f"serial {d['serial']}" if d.get("serial") 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.
try:
stats["cve_findings"] = _run_cve_scan(db, seen_asset_ids)
except Exception as e:
stats["errors"].append(f"cve scan: {e}")
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 Aruba, "
"%d without a version, %d CVE findings, %d inactivated, %d reactivated",
stats["devices"], stats["assets_matched"], stats["assets_created"],
stats["aruba_devices"], stats["no_version"], stats["cve_findings"],
stats["assets_inactivated"], stats["assets_reactivated"])
return stats
def _run_cve_scan(db: Session, asset_ids: set) -> int:
"""CVE pass over the assets this sync touched — both Aruba paths.
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 the three 2023 ArubaOS-Switch CVEs
exist only on the CPE side (HPE states them as prose that carries no
machine-readable bound). 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():
if not c5.aruba_key(asset.operating_system or ""):
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
try:
total += c5.scan_asset_aruba(db, asset, index, new_ids, touched=touched)
except Exception as e:
logger.warning("Netdisco Aruba 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
+5 -2
View File
@@ -1,6 +1,7 @@
"""Sync run ledger + failure alerting.
Every sync job Wazuh, Nessus, Intune, vCenter, IGEL; button or scheduler
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.
@@ -35,6 +36,7 @@ SOURCES = {
"intune": "Microsoft Intune",
"vcenter": "VMware vCenter",
"igel": "IGEL UMS",
"netdisco": "Netdisco",
}
# A source counts as configured when its config setting exists and is non-empty.
CONFIG_KEYS = {
@@ -43,8 +45,9 @@ CONFIG_KEYS = {
"intune": "intune_config",
"vcenter": "vcenter_config",
"igel": "igel_config",
"netdisco": "netdisco_config",
}
# Inventory syncs (Intune/vCenter/IGEL) are nightly: 36h means "missed one
# Inventory syncs (Intune/vCenter/IGEL/Netdisco) 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).
+178 -2
View File
@@ -632,6 +632,181 @@ function VCenterCard() {
}
// Netdisco (Netdisco 2 REST API, /api/v1).
// Registers every device Netdisco has discovered as an asset with its firmware
// version, then runs HPE Aruba CVE detection on the ArubaOS / ArubaOS-CX /
// ArubaOS-Switch ones. A switch runs no agent, is enrolled in no MDM and shows
// a network scanner an open port and no version — this is the only inventory
// source that can see it.
function NetdiscoCard() {
const [cfg, setCfg] = useState({ host: '', port: 5000, use_https: false, verify_ssl: true, username: '', auto_create_assets: true });
const [apiKey, setApiKey] = useState('');
const [password, setPassword] = useState('');
const [secretSet, setSecretSet] = useState(false);
const [configured, setConfigured] = useState(false);
const [status, setStatus] = useState<{ message: string; type: string }>({ message: '', type: '' });
const [busy, setBusy] = useState(false);
useEffect(() => {
api.get('/api/v1/settings/netdisco_config')
.then(r => {
if (!r.data?.value) return;
const p = JSON.parse(r.data.value);
setCfg({
host: p.host || '', port: p.port || 5000,
use_https: !!p.use_https,
verify_ssl: p.verify_ssl !== false,
username: p.username || '',
auto_create_assets: p.auto_create_assets !== false,
});
setSecretSet(!!(p.api_key || p.password));
setConfigured(!!p.host);
})
.catch(() => {});
}, []);
const save = async () => {
setBusy(true); setStatus({ message: 'Saving…', type: 'loading' });
try {
const payload: any = { ...cfg, port: Number(cfg.port) || 5000 };
payload.api_key = apiKey.trim(); // blank → backend keeps stored secret
payload.password = password.trim();
await api.put('/api/v1/settings/netdisco_config', { value: JSON.stringify(payload) });
if (apiKey.trim() || password.trim()) { setSecretSet(true); setApiKey(''); setPassword(''); }
setConfigured(!!cfg.host);
setStatus({ message: 'Netdisco settings saved.', type: 'success' });
} catch (e: any) {
setStatus({ message: e?.response?.data?.detail || 'Save failed', type: 'error' });
} finally { setBusy(false); }
};
const test = async () => {
setBusy(true); setStatus({ message: 'Testing…', type: 'loading' });
try {
const r = await api.post('/api/v1/integrations/netdisco/test');
setStatus(r.data?.ok && !r.data?.error
? { message: `OK — ${r.data.device_count ?? '?'} devices, ${r.data.aruba_count ?? 0} of them Aruba firmware (auth: ${r.data.auth})`, type: 'success' }
: { message: `Failed at ${r.data?.step}: ${r.data?.error}`, type: 'error' });
} catch (e: any) {
setStatus({ message: e?.response?.data?.detail || 'Test failed', type: 'error' });
} finally { setBusy(false); }
};
const sync = async () => {
setBusy(true); setStatus({ message: 'Starting sync…', type: 'loading' });
try {
const started = await api.post('/api/v1/integrations/netdisco/sync');
const attached = started.data?.status === 'already_running';
setStatus({ message: attached ? 'A sync was already running — attaching…' : 'Sync running…', type: 'loading' });
const poll = async () => {
try {
const s = await api.get('/api/v1/integrations/netdisco/sync/status');
if (s.data?.running) { setTimeout(poll, 3000); return; }
const d = s.data?.result;
if (s.data?.error) {
setStatus({ message: `Sync failed: ${s.data.error}`, type: 'error' });
} else if (d?.skipped) {
setStatus({ message: 'Another sync was already running — this trigger was skipped.', type: 'warning' });
} else if (d) {
const parts = [
`${d.devices ?? 0} devices`,
`${d.assets_matched ?? 0} matched`,
d.assets_created ? `${d.assets_created} created` : null,
d.assets_reactivated ? `${d.assets_reactivated} reactivated` : null,
d.assets_inactivated ? `${d.assets_inactivated} inactivated` : null,
`${d.aruba_devices ?? 0} Aruba`,
d.no_version ? `${d.no_version} without a version` : null,
`${d.cve_findings ?? 0} CVE findings`,
].filter(Boolean);
setStatus({ message: `Sync done — ${parts.join(', ')}`, type: 'success' });
} else {
setStatus({ message: attached
? 'The running sync finished before we could read its result — check the log.'
: 'Sync finished, but reported no result — check the backend log.',
type: 'warning' });
}
setBusy(false);
} catch { setTimeout(poll, 3000); }
};
setTimeout(poll, 3000);
} catch (e: any) {
setStatus({ message: e?.response?.data?.detail || 'Sync failed', type: 'error' });
setBusy(false);
}
};
const inputCls = "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-truevuln-blue focus:ring-truevuln-blue sm:text-sm h-11 px-3 font-mono";
return (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mt-8">
<h3 className="text-lg font-bold text-gray-900 font-mono mb-1 border-b border-gray-100 pb-2 flex items-center gap-2">
<span>🔌</span> Netdisco (switches &amp; routers)
{configured
? <span className="rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Configured</span>
: <span className="rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 ring-1 ring-inset ring-gray-300">Not set</span>}
</h3>
<p className="text-xs text-gray-500 mb-4">
Reads the <span className="font-mono">deviceinventory</span> report and registers every discovered
device as an asset with its firmware version. CVEs are detected for HPE Aruba firmware
ArubaOS-CX, ArubaOS-Switch (2530/2930F/5400R) and ArubaOS Mobility from NVD <em>and</em> HPE&apos;s
own CVE records. Other vendors are inventoried without a CVE verdict. Read-only: nothing is
ever written back to a device.
</p>
<div className="space-y-3">
<div className="grid grid-cols-4 gap-3">
<div className="col-span-3">
<label className="block text-xs font-medium text-gray-700 font-mono">Netdisco host</label>
<input className={inputCls} value={cfg.host} onChange={e => setCfg({ ...cfg, host: e.target.value })} placeholder="netdisco.local" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 font-mono">Port</label>
<input className={inputCls} value={cfg.port} onChange={e => setCfg({ ...cfg, port: Number(e.target.value) || 5000 })} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 font-mono">API key <span className="text-gray-400">(recommended)</span></label>
<input type="password" className={inputCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={secretSet ? '•••••••• (set — type to replace)' : '948e17690498f7a3496510f7b3128d1a'} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 font-mono">Username <span className="text-gray-400">(alternative)</span></label>
<input className={inputCls} value={cfg.username} onChange={e => setCfg({ ...cfg, username: e.target.value })} placeholder="truevuln-ro" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 font-mono">Password</label>
<input type="password" className={inputCls} value={password} onChange={e => setPassword(e.target.value)} placeholder={secretSet ? '••••••••' : ''} />
</div>
</div>
<p className="text-xs text-gray-500 -mt-1">
Either is enough: the username and password are POSTed once to <span className="font-mono">/login</span>,
which is Netdisco&apos;s own way of minting exactly such an API key so the key is the same
mechanism without the password on the wire. Leave both empty only for an instance that
requires no authentication at all (the public demo does not; a production one must).
</p>
<div className="flex flex-wrap gap-4 text-sm">
<label className="flex items-center gap-1.5 font-mono text-gray-600" title="Netdisco's own web server speaks plain HTTP only — tick this when a reverse proxy terminates TLS in front of it"><input type="checkbox" checked={cfg.use_https} onChange={e => setCfg({ ...cfg, use_https: e.target.checked })} /> HTTPS</label>
<label className="flex items-center gap-1.5 font-mono text-gray-600" title="Only applies with HTTPS — untick for a reverse proxy with an internal CA certificate"><input type="checkbox" checked={cfg.verify_ssl} onChange={e => setCfg({ ...cfg, verify_ssl: e.target.checked })} disabled={!cfg.use_https} /> Verify SSL</label>
<label className="flex items-center gap-1.5 font-mono text-gray-600"><input type="checkbox" checked={cfg.auto_create_assets} onChange={e => setCfg({ ...cfg, auto_create_assets: e.target.checked })} /> Auto-create assets</label>
</div>
<p className="text-xs text-gray-500 -mt-1">
Port 5000 over plain HTTP is a Netdisco install&apos;s default its bundled web server supports
no TLS. Tick HTTPS (and set the port) when it is fronted by a reverse proxy.
</p>
</div>
{status.message && (
<p className={`mt-3 text-sm font-mono ${status.type === 'error' ? 'text-red-600' : status.type === 'success' ? 'text-green-600' : status.type === 'warning' ? 'text-amber-600' : 'text-blue-600 animate-pulse'}`}>{status.message}</p>
)}
<div className="flex justify-end gap-2 mt-4">
<button onClick={test} disabled={busy} className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50">Test</button>
<button onClick={sync} disabled={busy || !configured} className="px-4 py-2 text-sm font-medium text-sky-700 bg-white border border-sky-300 rounded-md hover:bg-sky-50 disabled:opacity-50">Sync now</button>
<button onClick={save} disabled={busy} className="px-4 py-2 text-sm font-medium text-white bg-truevuln-blue rounded-md hover:bg-blue-700 disabled:opacity-50">Save</button>
</div>
</div>
);
}
// IGEL UMS (IGEL Management Interface, IMI API v3).
// Registers the UMS server AND every endpoint device it manages as assets,
// then runs IGEL OS CVE detection on each. A thin client runs no agent, is not
@@ -2079,14 +2254,15 @@ export default function SettingsPage() {
)}
{/* AI Remediation (OpenRouter) + Microsoft Intune + VMware
vCenter + IGEL UMS global system config, admin-only
like every other card here. */}
vCenter + IGEL UMS + Netdisco global system config,
admin-only like every other card here. */}
{userRole === 'admin' && (
<>
<OpenRouterCard />
<IntuneCard />
<VCenterCard />
<IgelCard />
<NetdiscoCard />
<GitHubPatCard />
<SyslogCard />
</>
+323
View File
@@ -0,0 +1,323 @@
"""HPE Aruba firmware CVE detection — run: python tests/test_aruba_firmware.py
Exercises the real production functions, not copies of their logic:
* `build_product_index` over a hand-built cvelistV5 ZIP the curated
patterns, the semver range extraction and the ArubaOS-Switch PROSE parser,
exactly as the nightly job runs them;
* `scan_asset_aruba` against that index the family gate, the release-branch
guard and the fix reported on the finding;
* `_in_range` on NVD's real cpeMatch for CVE-2023-39266, the ArubaOS-Switch
flaw that carries a machine-readable bound only on the CPE side.
Every affected[] block below is the real one, copied verbatim from cvelistV5.
"""
import json
import os
import sys
import tempfile
import zipfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import cvelistv5_scan_service as c5
from app.services import app_cve_scanner_service as cpe
# --- CVE-2026-73749: unauthenticated RCE in AOS-CX. -----------------------
# One INCLUSIVE range per release branch, each with its own floor. The floors
# are what keep a 10.13 switch away from the 10.17 bound.
CX_73749 = [
{"defaultStatus": "affected", "product": "AOS-CX",
"vendor": "Hewlett Packard Enterprise (HPE)",
"versions": [
{"lessThanOrEqual": "10.18.0001", "status": "affected",
"version": "10.18.0000", "versionType": "semver"},
{"lessThanOrEqual": "10.17.1021", "status": "affected",
"version": "10.17.0000", "versionType": "semver"},
{"lessThanOrEqual": "10.16.1051", "status": "affected",
"version": "10.16.0000", "versionType": "semver"},
{"lessThanOrEqual": "10.13.1180", "status": "affected",
"version": "10.13.0000", "versionType": "semver"},
{"lessThanOrEqual": "10.10.1180", "status": "affected",
"version": "10.10.0000", "versionType": "semver"},
]},
]
# --- CVE-2026-44880: authenticated RCE in AOS-CX. -------------------------
# Same shape, one branch stated EXCLUSIVELY (10.18.0000 < 10.18.0001) — that
# one names its own fix, the inclusive ones do not.
CX_44880 = [
{"defaultStatus": "affected", "product": "AOS-CX",
"vendor": "Hewlett Packard Enterprise (HPE)",
"versions": [
{"status": "affected", "version": "10.17.0000",
"lessThanOrEqual": "10.17.1020", "versionType": "semver"},
{"status": "affected", "version": "10.16.0000",
"lessThanOrEqual": "10.16.1050", "versionType": "semver"},
{"status": "affected", "version": "10.13.0000",
"lessThanOrEqual": "10.13.1170", "versionType": "semver"},
{"status": "affected", "version": "10.18.0000",
"lessThan": "10.18.0001", "versionType": "semver"},
]},
]
# --- CVE-2026-44857: PAPI stack overflow, Mobility controllers. -----------
# The wireless OS, four-component versions, and one entry (10.8.0.0) with NO
# upper bound at all — "affected, fix not stated", which cannot become a
# per-host verdict.
AOS_44857 = [
{"vendor": "Hewlett Packard Enterprise (HPE)",
"product": "HPE Aruba Networking Wireless Operating System (AOS)",
"defaultStatus": "affected",
"versions": [
{"status": "affected", "version": "8.13.0.0",
"lessThanOrEqual": "8.13.1.1", "versionType": "semver"},
{"status": "affected", "version": "8.12.0.0",
"lessThanOrEqual": "8.12.0.6", "versionType": "semver"},
{"status": "affected", "version": "8.10.0.0",
"lessThanOrEqual": "8.10.0.21", "versionType": "semver"},
{"status": "affected", "version": "10.7.0.0",
"lessThanOrEqual": "10.7.2.2", "versionType": "semver"},
{"status": "affected", "version": "10.8.0.0", "versionType": "semver"},
{"status": "affected", "version": "10.4.0.0",
"lessThanOrEqual": "10.4.1.10", "versionType": "semver"},
]},
]
# --- CVE-2023-39266 / CVE-2024-26303: ArubaOS-Switch, stated as PROSE. ----
# No lessThan, no lessThanOrEqual — the bound is a sentence inside `version`.
AOSS_39266 = [
{"defaultStatus": "affected", "product": "ArubaOS-Switch",
"vendor": "Hewlett Packard Enterprise",
"versions": [
{"status": "affected",
"version": "ArubaOS-Switch 16.11.xxxx: KB/WC/YA/YB/YC.16.11.0012 and below."},
{"status": "affected",
"version": "ArubaOS-Switch 16.10.xxxx: KB/WC/YA/YB/YC.16.10.0025 and below."},
{"status": "affected",
"version": "ArubaOS-Switch 16.09.xxxx: All versions."},
{"status": "affected",
"version": "ArubaOS-Switch 15.xx.xxxx: 15.16.0025 and below."},
]},
]
AOSS_26303 = [
{"defaultStatus": "affected", "product": "ArubaOS-S Switch",
"vendor": "Hewlett Packard Enterprise (HPE)",
"versions": [
{"status": "affected",
"version": "ArubaOS-Switch 16.11.xxxx: KB/WC/YA/YB/YC.16.11.0015 and below"},
{"status": "affected",
"version": "ArubaOS-Switch 16.10.xxxx: KB/WC/YA/YB/YC - All versions. "},
{"status": "affected",
"version": "ArubaOS-Switch 16.09.xxxx: All versions. "},
]},
]
# Must NOT enter the index: HPE files the whole Aruba management stack under
# the same vendor, and every one of those carries a version line that would
# compare nonsensically against a switch's firmware.
CLEARPASS = [
{"vendor": "Hewlett Packard Enterprise (HPE)",
"product": "HPE Aruba Networking ClearPass Policy Manager",
"versions": [{"status": "affected", "version": "6.11.0",
"lessThan": "6.11.9", "versionType": "semver"}]},
]
CENTRAL = [
{"vendor": "Hewlett Packard Enterprise (HPE)",
"product": "HPE Aruba Networking Central",
"versions": [{"status": "affected", "version": "2.5.7",
"lessThanOrEqual": "2.5.9", "versionType": "semver"}]},
]
RECORDS = {
"CVE-2026-73749": CX_73749,
"CVE-2026-44880": CX_44880,
"CVE-2026-44857": AOS_44857,
"CVE-2023-39266": AOSS_39266,
"CVE-2024-26303": AOSS_26303,
"CVE-2026-11111": CLEARPASS,
"CVE-2026-22222": CENTRAL,
}
# NVD's real cpeMatch for CVE-2023-39266 — the AOS-S half that only the CPE
# path can see, because cvelistV5 states it as the prose above.
NVD_39266 = {"vulnerable": True,
"criteria": "cpe:2.3:o:hpe:arubaos-switch:*:*:*:*:*:*:*:*",
"versionStartIncluding": "16.11.0001",
"versionEndIncluding": "16.11.0012"}
class FakeAsset:
def __init__(self, os_version, operating_system, hostname="sw-01"):
self.id = 1
self.hostname = hostname
self.operating_system = operating_system
self.os_version = os_version
def _build_index():
"""Run the REAL index build over a ZIP shaped like cvelistV5's."""
fd, path = tempfile.mkstemp(suffix=".zip")
os.close(fd)
with zipfile.ZipFile(path, "w") as zf:
for cve, affected in RECORDS.items():
zf.writestr(
f"cvelistV5-main/cves/{cve.split('-')[1]}/x/{cve}.json",
json.dumps({"cveMetadata": {"cveId": cve},
"containers": {"cna": {"affected": affected}}}))
orig_zip, orig_ensure, orig_store = c5._ZIP_PATH, c5._ensure_zip, c5._store_index
c5._ZIP_PATH = path
c5._ensure_zip = lambda force=False: True
c5._store_index = lambda db, index: None
try:
return c5.build_product_index(None)
finally:
c5._ZIP_PATH, c5._ensure_zip, c5._store_index = orig_zip, orig_ensure, orig_store
os.unlink(path)
def _scan(index, installed, operating_system):
"""Run the REAL scan_asset_aruba, capturing what it would upsert."""
found = []
orig_upsert, orig_stale = cpe._upsert, c5._resolve_stale_appliance
c5.cpe._upsert = lambda db, asset, name, version, c, new_ids, **kw: (
found.append((c["cve"], c["fixed"], name, version, kw.get("vendor"))),
new_ids.append(len(new_ids)))
c5._resolve_stale_appliance = lambda *a, **kw: 0
try:
c5.scan_asset_aruba(None, FakeAsset(installed, operating_system),
index, [], touched=set())
finally:
c5.cpe._upsert, c5._resolve_stale_appliance = orig_upsert, orig_stale
return sorted(found)
def demo():
index = _build_index()
# --- only the three switch/controller families reach a curated key ----
assert set(index) == {"aruba-cx", "aruba-switch", "aruba-os"}, index.keys()
assert {e["cve"] for e in index["aruba-cx"]} == {"CVE-2026-73749", "CVE-2026-44880"}
assert {e["cve"] for e in index["aruba-switch"]} == {"CVE-2023-39266", "CVE-2024-26303"}
assert {e["cve"] for e in index["aruba-os"]} == {"CVE-2026-44857"}
# Every indexed entry carries a real upper bound — an entry that says
# "affected" and names no fix cannot become a per-host verdict.
assert all(e.get("lt") or e.get("lte") for e in index["aruba-os"])
# HPE's one bound-less entry ("10.8.0.0", affected, nothing further) is
# read as the CLOSED range [10.8.0.0, 10.8.0.0] — the same conservative
# reading the shared extractor gives an exact version anywhere else. A
# gateway on exactly that build is flagged; the rest of the 10.8 line is
# under-reported rather than flagged against a bound HPE never wrote.
assert [c for c, *_ in _scan(index, "10.8.0.0", "ArubaOS")] == ["CVE-2026-44857"]
assert _scan(index, "10.8.0.1", "ArubaOS") == []
assert _scan(index, "10.8.1.4", "ArubaOS") == []
# --- a real estate's four switches ------------------------------------
# An AOS-CX 6300 reporting PL.10.13.1005: inside the 10.13
# branch of both current flaws. The fix is stated as ">bound" because HPE
# names the last BROKEN build, not the fixed one.
assert _scan(index, "10.13.1005", "ArubaOS-CX") == [
("CVE-2026-44880", ">10.13.1170", "ArubaOS-CX", "10.13.1005",
"HPE Aruba Networking"),
("CVE-2026-73749", ">10.13.1180", "ArubaOS-CX", "10.13.1005",
"HPE Aruba Networking"),
]
# A CX switch on PL.10.08.1010: neither record lists a 10.08 branch, and
# the zero-padded middle component is why these are compared numerically —
# "10.08.1010" sorts ABOVE "10.13.1180" as a string.
assert _scan(index, "10.08.1010", "ArubaOS-CX") == []
# A 2930F on WC.16.11.0016 and a 2530 on YA.16.11.0027: both past the
# 16.11 bounds of both switch advisories.
assert _scan(index, "16.11.0016", "ArubaOS-Switch") == []
assert _scan(index, "16.11.0027", "ArubaOS-Switch") == []
# --- the branch guard, which is the whole false-positive story --------
# A 10.13 switch must never be matched against the 10.17 bound, and vice
# versa: each finding has to name the fix ITS branch can actually reach.
assert [(c, f) for c, f, *_ in _scan(index, "10.17.1010", "ArubaOS-CX")] == [
("CVE-2026-44880", ">10.17.1020"), ("CVE-2026-73749", ">10.17.1021")]
# 10.17.1021 is fixed for -44880 (bound 1020) and still open for -73749.
assert [c for c, *_ in _scan(index, "10.17.1021", "ArubaOS-CX")] == ["CVE-2026-73749"]
assert _scan(index, "10.17.1022", "ArubaOS-CX") == []
# The one exclusive bound names its own fix.
assert ("CVE-2026-44880", "10.18.0001") in [
(c, f) for c, f, *_ in _scan(index, "10.18.0000", "ArubaOS-CX")]
# --- families never cross --------------------------------------------
# A Mobility controller on 10.7.2.2 and a CX switch on 10.13.1005 are both
# "10.x" and share nothing. Each answers only to its own product's ranges.
assert [c for c, *_ in _scan(index, "10.7.2.2", "ArubaOS")] == ["CVE-2026-44857"]
assert _scan(index, "10.7.2.2", "ArubaOS-CX") == []
assert _scan(index, "10.13.1005", "ArubaOS") == []
assert _scan(index, "16.11.0010", "ArubaOS-CX") == []
# …and the controller's own branches do not cross either: 8.12.0.6 is the
# last affected 8.12 build, 8.13.1.1 the last affected 8.13 one.
assert [c for c, *_ in _scan(index, "8.12.0.6", "ArubaOS")] == ["CVE-2026-44857"]
assert _scan(index, "8.12.0.7", "ArubaOS") == []
assert [c for c, *_ in _scan(index, "8.13.1.0", "ArubaOS")] == ["CVE-2026-44857"]
assert _scan(index, "8.14.0.0", "ArubaOS") == []
# --- ArubaOS-Switch: the prose bounds ---------------------------------
# A switch that has NOT been updated: inside both 16.11 prose bounds.
assert [(c, f) for c, f, *_ in _scan(index, "16.11.0010", "ArubaOS-Switch")] == [
("CVE-2023-39266", ">16.11.0012"), ("CVE-2024-26303", ">16.11.0015")]
# …and one that sits between the two bounds gets only the newer flaw.
assert [c for c, *_ in _scan(index, "16.11.0013", "ArubaOS-Switch")] == [
"CVE-2024-26303"]
# The 16.10 branch has its own bound in one record; "and below" of the
# 16.11 branch must never reach it.
assert [c for c, *_ in _scan(index, "16.10.0020", "ArubaOS-Switch")] == [
"CVE-2023-39266"]
assert _scan(index, "16.10.0026", "ArubaOS-Switch") == []
# "All versions" carries no bound: not indexed, so a 16.09 switch is
# under-reported rather than given a finding with no fix to reach.
assert _scan(index, "16.09.0004", "ArubaOS-Switch") == []
# The 15.x line keeps its own bound.
assert [c for c, *_ in _scan(index, "15.16.0024", "ArubaOS-Switch")] == [
"CVE-2023-39266"]
assert _scan(index, "15.16.0026", "ArubaOS-Switch") == []
# --- the asset-side gate ---------------------------------------------
assert _scan(index, "10.13.1005", "HPE Aruba Networking ClearPass") == []
assert _scan(index, "10.13.1005", "cisco ios") == []
assert _scan(index, "10.13.1005", "Windows Server 2022") == []
# No version on the asset → no verdict, never a guess.
assert _scan(index, "", "ArubaOS-CX") == []
# --- version normalisation -------------------------------------------
# The two-letter code line names the hardware family the image is for, not
# the version; HPE's own advisories bound the numbers only.
assert c5.aruba_version("WC.16.11.0016") == "16.11.0016"
assert c5.aruba_version("YA.16.11.0027") == "16.11.0027"
assert c5.aruba_version("PL.10.13.1005") == "10.13.1005"
assert c5.aruba_version("KB/WC/YA.16.11.0012") == "16.11.0012"
assert c5.aruba_version("ArubaOS (MODEL: Aruba7005), Version 8.6.0.7") == "8.6.0.7"
assert c5.aruba_version("") is None
assert c5.aruba_version("Chassis") is None
# --- CVE-2023-39266: the CPE-only half --------------------------------
e = cpe._resolve_os("ArubaOS-Switch")
assert e and e["cpe"] == "cpe:2.3:o:hpe:arubaos-switch", e
assert cpe._in_range("16.11.0010", NVD_39266)
assert not cpe._in_range("16.11.0016", NVD_39266) # the estate's own build
assert not cpe._in_range("10.13.1005", NVD_39266) # a CX version entirely
# Each family gets its own CPE, and the CX/AOS ones are anchored so
# "arubaos-cx" cannot fall through to the bare ArubaOS entry.
assert cpe._resolve_os("ArubaOS-CX")["cpe"] == "cpe:2.3:o:hpe:arubaos-cx"
assert cpe._resolve_os("ArubaOS")["cpe"] == "cpe:2.3:o:arubanetworks:arubaos"
# A Cisco switch, as Netdisco names it, must not be read as Apple iOS —
# 15.2 sits below every iOS bound ever written.
assert cpe._resolve_os("cisco ios") is None
assert cpe._resolve_os("iOS 18.1")["label"] == "Apple iOS"
print("test_aruba_firmware: OK")
def test_aruba_firmware():
demo()
if __name__ == "__main__":
demo()
+174
View File
@@ -0,0 +1,174 @@
"""Netdisco inventory mapping — run: python tests/test_netdisco_inventory.py
Exercises the real production functions against the real API payload:
* `NetdiscoClient.get_devices` over the JSON the deviceinventory report
actually returns (the public demo instance's rows, verbatim, plus the four
Aruba/HP rows from a customer export);
* `netdisco_service.aruba_family` which of the three Aruba firmware lines a
row is, and the far more important question of when the answer is "none";
* `netdisco_service.os_and_version` what is written onto the asset, which
is what both CVE paths then key off.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.integrations.netdisco_client import NetdiscoClient
from app.services import netdisco_service as nd
from app.services import app_cve_scanner_service as cpe
# Four rows in the shape a real deviceinventory export has (two ProVision
# switches, two AOS-CX), in the API's own JSON spelling. Names, addresses and
# serials are placeholders; the models and firmware strings are what matters
# and are the genuine ones. `version` is the report's "OS Version" column —
# the firmware string with HPE's two-letter code-line prefix.
EXPORT = [
{"device_name": "sw-access-01", "device_details": "Chassis", "ip": "192.0.2.25",
"location": "Example site", "model": "JL557A|2930F-48G-PoE+-4SFP-740W",
"serial": "SN00000000A1", "vendor": "hp", "os": "hp", "version": "WC.16.11.0016"},
{"device_name": "sw-access-02", "device_details": "Chassis", "ip": "192.0.2.21",
"location": "Example site", "model": "J9774A|2530-8G-PoEP",
"serial": "SN00000000A2", "vendor": "hp", "os": "hp", "version": "YA.16.11.0027"},
{"device_name": "sw-access-03", "device_details": "Chassis", "ip": "192.0.2.8",
"location": '""""', "model": "R8N87A", "serial": "SN00000000B1",
"vendor": "aruba", "os": "arubaos-cx", "version": "PL.10.08.1010"},
{"device_name": "sw-access-04", "device_details": "Chassis", "ip": "192.0.2.20",
"location": '""""', "model": "R8N87A", "serial": "SN00000000B2",
"vendor": "aruba", "os": "arubaos-cx", "version": "PL.10.13.1005"},
]
# A row from the public demo instance, unauthenticated, verbatim.
DEMO_ROW = {"device_name": "leaf01", "vendor": "Cumulus Networks", "location": "leaves",
"serial": "a0:00:00:00:00:11",
"version": "Cumulus Linux 3.5.1 (Linux Kernel 4.1.33-1+cl3u11)",
"model": "3.5.1|VX Chassis", "device_details": "leaf01",
"ip": "192.168.0.11", "os": "cumulus"}
# A device Netdisco knows nothing about yet — no canonical address, so nothing
# to pin an asset to.
NO_IP_ROW = dict(DEMO_ROW, ip="", device_name="ghost")
def _client_devices(rows):
"""Run the REAL get_devices over a canned API answer."""
c = NetdiscoClient(host="netdisco.local", api_key="948e17690498f7a3496510f7b3128d1a")
c._get = lambda path: rows
try:
return c.get_devices()
finally:
c.close()
def demo():
# --- the client's own mapping ----------------------------------------
devices = _client_devices(EXPORT + [DEMO_ROW, NO_IP_ROW])
# A row without a canonical IP is dropped: Netdisco keys a device on that
# address, so an asset created from such a row would fork a new one on
# every sync.
assert [d["name"] for d in devices] == [
"sw-access-01", "sw-access-02", "sw-access-03", "sw-access-04", "leaf01"]
assert devices[0] == {
"ip": "192.0.2.25", "name": "sw-access-01", "details": "Chassis",
"location": "Example site", "model": "JL557A|2930F-48G-PoE+-4SFP-740W",
"serial": "SN00000000A1", "vendor": "hp", "os": "hp",
"os_version": "WC.16.11.0016"}
# Netdisco quotes an empty location as a literal '""""' in the CSV export
# and passes it through the API the same way — an empty location, not a
# location called '"'.
assert devices[2]["location"] == ""
# --- which firmware line each row is ----------------------------------
fams = [nd.aruba_family(d["os"], d["vendor"], d["os_version"]) for d in devices]
assert fams == ["aruba-switch", "aruba-switch", "aruba-cx", "aruba-cx", None]
# The Mobility controllers: os slug and four-component version.
assert nd.aruba_family("aos-w", "aruba", "8.13.1.0") == "aruba-os"
assert nd.aruba_family("airos", "aruba", "10.7.2.2") == "aruba-os"
# --- and when the answer has to be "none" -----------------------------
# Ubiquiti's AirOS answers the SAME os slug as an Aruba controller. Read as
# one, a 6.1.7 access point would be compared against ArubaOS 6.x ranges.
assert nd.aruba_family("airos", "ubiquiti", "6.1.7") is None
# "hp" is what every HP device answers, not just a ProVision switch. Only
# the 15.x/16.x firmware lines HPE bounds its advisories by are claimed.
assert nd.aruba_family("hp", "hp", "WC.16.11.0016") == "aruba-switch"
assert nd.aruba_family("hp", "hp", "1.2.3") is None
assert nd.aruba_family("hp", "hp", "H.10.98") is None
# A CX row whose version came back truncated: two components cannot be
# bounded against a three-component branch range.
assert nd.aruba_family("arubaos-cx", "aruba", "10.13") is None
assert nd.aruba_family("arubaos-cx", "aruba", "") is None
# A controller version with only three components is not the four-part
# Mobility scheme — no verdict rather than a guessed one.
assert nd.aruba_family("aos-w", "aruba", "8.13.1") is None
# Everything else Netdisco discovers.
assert nd.aruba_family("cumulus", "Cumulus Networks", "Cumulus Linux 3.5.1") is None
assert nd.aruba_family("ios", "cisco", "15.2(7)E3") is None
# --- what lands on the asset -----------------------------------------
# The canonical family label and the NUMERIC version: the letters are the
# hardware code line, and no advisory bounds them.
assert nd.os_and_version(devices[0]) == ("ArubaOS-Switch", "16.11.0016")
assert nd.os_and_version(devices[1]) == ("ArubaOS-Switch", "16.11.0027")
assert nd.os_and_version(devices[2]) == ("ArubaOS-CX", "10.08.1010")
assert nd.os_and_version(devices[3]) == ("ArubaOS-CX", "10.13.1005")
# A non-Aruba device keeps Netdisco's own strings, vendor first. Vendor
# first is not cosmetic: Netdisco calls a Cisco switch's OS "ios", and a
# bare "ios" on an asset is a product name that belongs to Apple.
assert nd.os_and_version(devices[4]) == (
"Cumulus Networks cumulus", "Cumulus Linux 3.5.1 (Linux Kernel 4.1.33-1+cl3u11)")
cisco = {"os": "ios", "vendor": "cisco", "os_version": "15.2(7)E3"}
assert nd.os_and_version(cisco) == ("cisco ios", "15.2(7)E3")
assert cpe._resolve_os("cisco ios") is None
assert cpe._resolve_os("cisco ios-xe") is None
# …and the platform filter has to agree with it: it decides whether an
# Apple CVE is even considered for this asset, and it used to answer
# "iphone_os" for anything with "ios" anywhere in the string.
assert cpe._os_family("cisco ios") is None
assert cpe._os_family("ios-xe") is None
assert cpe._os_family("iOS 18.1") == "iphone_os"
# …and the version that a Cisco asset carries would otherwise have been
# readable as an Apple one, which is what makes the anchor above matter.
assert cpe._clean_version("15.2(7)E3") == "15.2"
# --- the scan side sees exactly what the sync wrote --------------------
from app.services import cvelistv5_scan_service as c5
for d in devices[:4]:
label, version = nd.os_and_version(d)
assert c5.aruba_key(label), label
assert cpe._resolve_os(label)["label"] == label
assert cpe._clean_version(version) == version
# --- the client's URL and auth shape ----------------------------------
# Plain HTTP on port 5000 is the DEFAULT, because Netdisco's own web server
# speaks no TLS at all; HTTPS is a reverse proxy in front of it.
c = NetdiscoClient(host="netdisco.local")
assert c.base_url == "http://netdisco.local:5000"
c.close()
c = NetdiscoClient(host="https://netdisco.local/", port=443, use_https=True)
assert c.base_url == "https://netdisco.local:443"
c.close()
# An API key short-circuits the login: it IS what a login returns.
c = NetdiscoClient(host="nd", api_key="abc", username="u", password="p")
c.login()
assert c.api_key == "abc"
c.close()
# No credentials at all is a valid configuration — the demo instance
# answers the report unauthenticated, and login() must not invent a call.
c = NetdiscoClient(host="nd")
c.login()
assert not c.api_key
c.close()
print("test_netdisco_inventory: OK")
def test_netdisco_inventory():
demo()
if __name__ == "__main__":
demo()