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:
+197
-1
@@ -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;")
|
||||
@@ -32,6 +32,7 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
|
||||
"intune_config",
|
||||
"vcenter_config",
|
||||
"igel_config",
|
||||
"netdisco_config",
|
||||
"github_pat",
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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 & 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'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'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'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 />
|
||||
</>
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user