84 Commits
Author SHA1 Message Date
vulncheck fd20512710 chore(tools): probe whether the Jev endpoint reports token usage
The nightly audit entry names a token count only when the endpoint sends a
usage block, which neither TypeSafe nor OpenRouter Decisions promises. The
probe asks one question about one public CVE and prints which of the two
wordings the audit entry will carry, instead of waiting for 06:00.

    docker compose exec backend python tools/probe_jev_usage.py
2026-09-20 12:56:25 +02:00
vulncheck c0ec7177a1 feat(audit): log what a Jev triage run cost and covered
A Jev run only left a line in the backend log, so nobody could tell from
the GUI how many API calls a night had cost or how much of the fleet it
covered.

Every run that did something now writes one audit entry (System/Auto,
AI_ANALYSIS_REQUESTED with resource_type "jev_triage"): "Jev triage: 412
CVEs checked on 87 hosts, 13,204 tokens (jev-1.13.0)", new_value with
checked, hosts, tokens, model, the CVEs answered, skipped_cves and error.
Hosts are the ACTIVE assets carrying an open finding for those CVEs.

Tokens come from the endpoint's usage block; a CVE whose answer is
unusable was still billed and counts. When no answer reported usage the
entry says so instead of claiming 0, and a run that answered nothing
claims nothing about the endpoint at all. A run with nothing pending
writes no entry — "0 CVEs" every night would bury the real rows.
2026-09-20 12:32:43 +02:00
vulncheck 063cdd7110 feat(patch-plan): page through the whole plan, export it as CSV
The plan endpoint capped at 100 groups, so everything below the top 100
was invisible and column sorting only sorted that cap. It now returns the
whole plan (?limit=N still truncates) and the page sorts and pages it
client-side with the shared Pagination bar, like Assets.

New GET /patch-plan/export streams the same plan as CSV in rank order,
named patch_plan_YYYYMMDD_HHMMSS.csv like the assets export, with the
exploited and check-config CVE lists spelled out in the row.

The Jev hint never read this list: it walks every open finding on ACTIVE
assets, one question per unanswered CVE, capped at MAX_CVES_PER_RUN a run.
2026-09-20 10:20:09 +02:00
vulncheck d138d8d557 feat(audit): log assets created by a sync
A manual create under Assets writes ASSET_CREATED, a sync-created asset
only reached the backend log ("IGEL sync: auto-created asset TX-11777"):
searching the audit log for "created" found users, never assets.

Every sync create site now calls asset_lifecycle.created, the create
counterpart of rename: one ASSET_CREATED entry per new asset, "Asset
created by <source> sync: <hostname>", new_value JSON with hostname and
source. Covered: Wazuh agents and manager nodes, Nessus, vCenter, IGEL,
Intune, Netdisco and the container imports (source = scanner). Defender
never creates. Assets created before this stay without an entry.
2026-09-19 11:31:20 +02:00
vulncheck e0646077d6 feat(triage): Jev over a TypeSafe key directly, or over OpenRouter
Jev could only run over OpenRouter. The AI settings card now also takes a
TypeSafe API key (setting typesafe_api_key, encrypted at rest like the
OpenRouter key, or env TYPESAFE_API_KEY). When it is set, Jev goes straight
to api.typesafe.ai/v1/systemone with the pinned jev-1.13.0. Without it, Jev
uses the OpenRouter key and OpenRouter Decisions with typesafe/jev-1.13.
Both endpoints take the same body and give the same answer. The card
shows "Configured" when either key is set, and the TypeSafe key has its
own Remove button.

JEV_MODEL is gone. It overrode the model on both endpoints, but the two use
different model ids. Adding or removing a TypeSafe key switched the
endpoint and left the other endpoint's model id in place. Every CVE then
failed as a CVE-specific 4xx and was skipped, up to 500 requests with
nothing written and no error reported. The model is now fixed per
endpoint.

For the same failure shape from any other cause, such as a changed schema,
five CVE-specific failures in a row now stop the run with an error. The
run no longer walks through every CVE.

A test checks the actual request: URL, bearer key, model and question.
2026-09-19 10:14:51 +02:00
vulncheck 518a67a4bb fix(netdisco): recognise LoadMaster rows that report os "LMOS"
The Netdisco commit that added a LoadMaster device class changed the
deviceinventory row from os "LoadMaster" to os "LMOS" (LoadMaster OS).
loadmaster_family() compared the slug to "loadmaster" exactly. On the new
rows it returned None, so the asset was written as "kemp LMOS" with the raw
build string, and scan_key() gave no scanner. Neither CVE pass ran on the
box. New LoadMaster CVEs would not have been reported, and open ones would
not have closed.

Both slugs are now accepted. The kemp/progress vendor check still gates the
match. Reproduced by running the report row from the field through the real
client mapping into the scanner: the field build 7.2.63.3 comes out clean,
7.2.48.0 and 7.2.54.17 come out affected, and the device name does not
change any result. A full sync before and after the fix confirms the
missing scan and the restored one.

The Sophos row from the same Netdisco update (vendor "sophos", os "sfos",
model "XGS4300_AM02_SFOS") goes through the same test and was already
correct: the field build 22.0.1 MR-1-Build490 comes out clean, and older
releases come out affected. It is pinned by a test now.
2026-09-19 10:08:43 +02:00
vulncheck 081cbb69da feat(triage): sortable patch plan columns
Every column header on the patch plan now sorts, with the same ↕/↑/↓ header
and asc/desc toggle as the Assets table. The whole plan is loaded in one
request, so sorting happens in the browser. Columns that hold lists
(Exploited, Check config) sort by how many CVEs they hold.

The # column keeps the backend's rank: exploited CVEs first, then summed
priority. That rank is the default order, and clicking # restores it after
sorting by another column.
2026-09-19 10:02:27 +02:00
vulncheck e7812690da fix(triage): show why the patch plan did not load; name Jev in the AI settings card
Against a backend without the patch-plan route, /patch-plan falls through
to /{vuln_id}, and the 422 response carries its detail as a list of
objects. The page passed that list straight into React, which cannot
render it, so the page came up blank. It now shows the HTTP status and
asks whether the backend is up to date.

The Jev toggle sat inside the "AI Remediation (OpenRouter)" card, so
nobody looking for "Jev" or "TypeSafe" found it. The card is now titled
"AI Remediation + Jev Triage (OpenRouter)" and says that the same
OpenRouter key serves Jev; no separate TypeSafe key is needed.
2026-09-19 10:01:19 +02:00
vulncheck dad8f4e0e0 feat(triage): patch plan ranked by exploited CVEs, with an advisory Jev config hint
The findings list says what is wrong, not what to patch first: one Chrome
update closes a hundred rows, and one KEV on a single host matters more
than all of them. The new /patch-plan page (GET
/api/v1/vulnerabilities/patch-plan) groups open findings (open,
patch_failed) on ACTIVE assets by the product to update, using the
vulnerability_packages rows, else package_name, else the CVE id. Each
group shows hosts, findings, CVEs, the fixed versions as reported (never
compared) and summed/max priority_score. Groups with CVEs exploited in the
wild (KEV / EUVD) rank first, then by summed priority. Every number comes
from data the tool already scores.

TypeSafe Jev (via OpenRouter Decisions, pinned typesafe/jev-1.13) is asked
one question per open CVE, nightly at 06:00 after the last ingest: is it
only exploitable with an optional feature or a non-default configuration?
It is the one triage question no feed answers. KEV, EUVD, EPSS, SSVC and
the CVSS vector already cover exploitation and attack vector, and exposure
is a property of the host, not of the CVE text. The probability is stored
in vulnerabilities.jev_config_dependent (migration 061); CVEs at 0.7 or
above show under "Check config" on the plan. It never closes a finding
(cross-source contract) and never enters priority_score. The job is off by
default (Settings, AI card, or JEV_TRIAGE_ENABLED) and reuses the
OpenRouter key. It sends only public CVE text. An account or network
failure stops the run unstamped; a failure specific to one CVE skips only
that CVE, so the job cannot stall on the same CVE every night.
2026-09-19 09:40:28 +02:00
vulncheck d3833e8e39 fix(vulnerabilities): name the Docker host of a container-image finding
Dockhand findings sit on the image asset, so the Host column of the
Vulnerabilities list showed "ghcr.io/…/app:latest" and never the machine
to patch. ec34e30 linked each image to its Docker host asset
(container_placements), but only the asset page read that link; the
vulnerability list never did.

The list now attaches runs_on ({id, hostname}) to every item, one query per
page, on every sort path, and the Host cell shows "on docker-01" under the
image name. An image without a link (the Docker host's name matched no
asset, or several) shows nothing extra: the image name stays the only trace,
as before, rather than a guessed host.
2026-09-18 11:43:48 +02:00
vulncheck bc6077ebc3 feat(advisories): split Threat Intel into KEV and advisory-feed tabs
The Advisories page mixed two different things in one scroll: the merged
KEV catalogs (CISA, ENISA EUVD, VulnCheck), which are matched against the
inventory and drive the alert mail, and the vendor/CERT RSS feeds, which are
reading material and match nothing. Its header showed "Last fetch" and
"Refresh now" above the KEV list, but both belonged to the RSS feeds alone;
the age of the KEV catalogs was shown nowhere.

The sidebar entry is now "Threat Intel" with two tabs. /advisories/kev puts
"Exploited & open in your environment" (the alert list) first and "Latest
KEV additions" below it. Its header shows each catalog's own last-fetch time,
or "not configured" for VulnCheck without a token. "Refresh catalogs" calls
the new POST /api/v1/advisories/kev/refresh, which forces all three past
their 24h cache and records failures per source. /advisories/feeds holds the
RSS feeds with their own fetch time, refresh and configuration. /advisories
redirects to the KEV tab, so bookmarks and the dashboard link keep working.
The sidebar now also highlights an entry on its sub-pages.
2026-09-18 11:24:47 +02:00
vulncheck 38d4faac0b feat(scan): detect Entra Connect Sync CVEs (CVE-2026-65673)
A server running Microsoft Entra Connect Sync 2.6.3.0 showed no CVEs,
because neither registry knew the product. CVE-2026-65673 (SQL injection,
local EoP, CVSS 7.8) affects every build from 1.0.0 up to 2.6.84.0 and is
stated in both sources: NVD as microsoft:entra_connect < 2.6.84.0, and the
cvelistV5 record as Microsoft / "Microsoft Entra Connect", 1.0.0 .. <2.6.84.0.

Both registries now match the product. The NVD path also queries the
pre-rename azure_active_directory_connect CPE (CVE-2021-36949, CVE-2017-8613),
and the cvelistV5 path also reads the "Microsoft Azure Active Directory
Connect 1.X.Y.Z / 2.0.X.Y" products from the same records.

The name is anchored and exact ("Microsoft Entra Connect Sync", "Microsoft
Azure AD Connect", plus the "synchronization services" row, which carries the
same build and dedups with it). The same server carries the Health Agent
(4.5.x) and the pass-through Authentication Agent / Agent Updater (1.5.x),
and 1.5.x sits inside the vulnerable range. The Provisioning Agent (Cloud
Sync) is a separate product and stays out. The cvelistV5 index goes to v39
so the new key is built. The finding closes through the normal app-scan
reconcile once the host reaches 2.6.84.0.
2026-09-18 11:05:04 +02:00
vulncheck ec34e307d3 feat(dockhand): link container images to their Docker host asset
Every image was its own asset in the flat asset list, between the servers,
and the Docker host's own asset (docker-01 from Wazuh/Nessus/vCenter) said
nothing about what ran on it; the host was only text in the image's
description.

The Dockhand sync now matches each environment's Docker hostname (docker info
Name from GET /api/host) against non-container, non-decommissioned assets:
exact name first, then FQDN vs short form. Several candidates and the
environment's public IP / host does not decide, or none: no link, the
description stays the only trace. A wrong host is worse than none. Links go
to the new container_placements table (060), rewritten by every sync, so an
image gone from a host loses its link with the next run. Findings stay on the
image asset; the cross-source retraction is unchanged.

The asset page hides container images by default (include_containers=false,
"Show container images"); a host row shows an "N images" badge that opens
GET /assets/{id}/containers, an image row names its hosts. The list reads
both directions in one query, so its statement count stays flat. The new
route is readonly-visible like /software.

Hawser needs nothing on our side: it is Dockhand's agent for remote hosts,
and every Hawser host is a Dockhand environment like any other.
2026-09-18 10:55:00 +02:00
vulncheck 48713bd619 feat(audit): record whether a login came from the web UI or the API
A script logging in through the API left the same "login OK via local as
readonly" entry as a browser login, with user agent "node" for both: the
Next.js proxy forwarded only Authorization, Cookie and Content-Type. It now
also forwards User-Agent and Sec-Fetch-Mode, and login success and failure
entries end in [Web-UI] (Sec-Fetch-Mode present) or [API]. No schema change.
2026-09-18 09:55:05 +02:00
vulncheck f176d6620b feat(scan): detect CVE-2026-84388 in the FortiPAM Chrome extension
Fortinet published FG-IR-26-168 on 2026-09-08 (CVSS 9.1, the Privileged
Access Agent extension proxies browser traffic for a malicious site); ten
days later NVD has nothing and cvelistV5 only a RESERVED id, so the
extension registry had no CPE to look up.

An extension entry can now carry `advisories`: rows read off the vendor's
advisory (start inclusive, fix exclusive), checked through the same
bounded _in_range. The FortiPAM entry (gkmiciealelfckimnoopefeplibjciih)
covers 8.0.0 ≤ v < 8.0.1.123: the CVRF lists 8.0.0 and 8.0.1 as affected,
the advisory text names 8.0.1.123, and the extension versions are the
advisory's plus a build number. 7.x is not listed and stays out. The NVD
lookup runs only for entries with a cpe, and the vendor fallback comes
from the entry instead of a hard-coded "Adobe". The finding closes through
the normal app-scan reconcile on update or removal.
2026-09-18 09:34:34 +02:00
vulncheck 8fe0cd3caa feat(dockhand): show on which Docker host an image runs
One asset per image is deliberate (env A must not retract what env B still
reports), but with the same image on a local and a Hawser-connected host
nothing said where it ran: the asset was named after the image and carried
no host at all.

The sync now writes the asset description per Dockhand environment: the
environment name, the Docker host's own hostname from GET /api/host
(docker info Name) when it differs, connection type, host and public IP
when set, the short image id, the containers (name + 12-char id as docker
ps shows them) and stacks. Different image ids on two hosts show which one
is still on the old build. Container ids change on every recreate and are
shown, never matched on; Dockhand exposes no Docker daemon id and Podman
has none. A failing /api/host only drops the hostname. ingest_image takes
an optional description that replaces the digest line; the push path is
unchanged. No schema change.
2026-09-18 09:34:33 +02:00
vulncheck 88a6bd47cc feat(audit): log asset renames done by a sync
A manual rename under Assets is audited, a sync-driven one left no trace:
Netdisco, vCenter, IGEL and Intune adopt the name the source reports once
they matched an asset on a pin, and the old name, still in a ticket or a
mail, then pointed at nothing.

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

_LM_BULLETIN holds (start, fix) per line for those CVEs, read off the Progress
release notes (Security Updates of 7.2.48.11/.12, 7.2.54.9/.10/.12/.13/.14/.17).
A CVE with a row is decided by the row alone, in the cvelistV5 and the NVD
pass, through the existing per-line rules. Lines the notes do not name stay
out, so a 7.1.35 box gets no verdict where the sources disagree. 7.2.48.0 now
also shows 7591 (fix 7.2.54.12), 1758 (7.2.54.14) and the four 2026 CVEs
(7.2.54.17); 2448/2449/3544 start at 7.2.48.10/.11 and stay off it.
2026-09-17 15:22:53 +02:00
vulncheck 2be2607972 refactor(ui): Scan Jobs sections collapse, sync runs expand per row
The page was one long wall: empty cards for unconfigured sources and a
Result column dumping every counter as key=value text. Sections are now
collapsible (.tv-section, same caret as the Data jobs drawer), unconfigured
sources share one line, and a sync run shows only the counters that moved;
the full stats grid and error list open on click. Badges and buttons use
tv-tag / tv-btn like the Vulnerabilities page.
2026-09-17 13:20:33 +02:00
vulncheck 4c2a1fb86a fix(docker): drop -dev packages, apply Debian security updates
A Grype scan of the backend image reported 1576 findings, 16 critical. Most
came from build headers nothing uses: every requirement ships a wheel (xmlsec
and lxml bundle their own libxml2 2.14), so libxml2-dev, libxmlsec1-dev,
libsasl2-dev, libldap2-dev, libssl-dev and pkg-config only pulled in
linux-libc-dev (1161 findings), libunbound8 and Debian's libxml2. The rest
were Debian fixes the python:3.11-slim base had not picked up yet (perl,
libc6, gzip, pcre2, sqlite, libevent) and old pip / setuptools / wheel.

The image now runs apt-get upgrade, upgrades pip, setuptools and wheel, and
installs requirements with --only-binary=:all:, so a requirement without a
wheel fails the build instead of needing a compiler. Rebuilt image: 187
findings, none critical, no High with a fix available; the remaining fixable
ones are Python 3.11 CVEs fixed only in 3.13+. App import, SAML/xmlsec and
the test suite run inside the image.
2026-09-17 12:22:53 +02:00
vulncheck 6da07b3630 feat(dockhand): rescan every image before the nightly import
Dockhand has no scan schedule: it scans on pull, on update and by hand, so an
image that is never pulled keeps its first scan and new CVEs in its packages
never reach the export. With "Rescan nightly" (scan_nightly, default off) the
02:50 UTC job calls POST /api/vulnerabilities/scan-all per environment
(Dockhand >= 1.0.37, synchronous with Accept: application/json, 3 h read
timeout) before reading that environment's export. A failed or partial rescan
lands in the run's errors and does not abort; the export still holds the
previous scan. Sync now never rescans. The token needs images:inspect.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Rows that already carry Defender's exact label are untouched, as before.
The heal and fill logic only acts on the OS placeholder or an empty column.
2026-09-15 12:58:00 +02:00
vulncheck 735a790ee2 fix(defender): repair the OS placeholder on rows the sync no longer reaches
d708a04 stops Defender labelling Chrome and Edge CVEs "Microsoft Windows"
and heals rows Defender still reports. Rows it dropped keep the placeholder:
rows MSRC took over, where the list said "Microsoft Windows" while the
detail page showed Edge, and closed rows.

Migration 057 touches only rows Defender created whose package is still the
asset's OS string. If another scanner recorded a package row, the parent
takes the earliest one's name and version. Otherwise, if the fixed version is
dotted and its major differs from the placeholder build (153.x against
10.0.x), name and version are cleared: unknown rather than wrong. Windows
CVEs fixed in 10.0.x and iOS CVEs keep their label. Data only; the downgrade
is a no-op.
2026-09-15 11:20:59 +02:00
vulncheck d708a04481 fix(defender): a Chrome or Edge CVE was labelled "Microsoft Windows"
Field report CVE-2026-87474: first seen by Defender TVM, the finding showed
package "Microsoft Windows", installed 10.0.26100.9445 (the Windows build)
and fixed in 153.0.8010.36 (Chrome). Rows whose CVE another scanner found
first were correct.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

CR and LTSR never share a year.month, so the build's first two fields name the
branch. The decision reuses vmware_release_service.is_affected: CR is one
line, each LTSR is its own line with the CU as update line (CVE-2025-4879 fixes
2402 in CU2 HF1 and CU3 HF1). A bound that resolves to no single catalog build
leaves the CVE undecided and held open, never guessed. Windows hosts only; the
Mac app shares the name and its 25.07.x numbering.
2026-09-14 11:14:00 +02:00
vulncheckandClaude Opus 5 330a4e21a3 fix(ui): der Data-Jobs-Drawer klappte fuer Read-Only auf ein leeres Nichts auf
Im Drawer war jedes der acht Kinder mit currentUser?.role !== 'readonly'
gegated, das <details> selbst nicht. Ein Read-Only-User sah damit die Huelle
samt "Data jobs"-Summary und klappte sie auf einen leeren Kasten auf. Nicht
durch 1807ea6 entstanden - der hat nur einen neunten Button hineingeschoben;
die Luecke kam mit e8e4707, als der Drawer entstand.

Jetzt traegt der Container das Gate, die acht identischen Gates darin
entfallen. Das ist das Muster, das Navigation.tsx schon anwendet:
visible.length === 0 -> return null, statt eine leere Huelle zu rendern.

Gleiche Klasse in policies: die Action-Spalte (Edit/Delete, canEdit bzw.
isAdmin) ist ein flex-gap-2-Div ohne eigenes Gate und blieb fuer Read-Only
als leerer Block in der gap-6-Reihe stehen.

Geprueft per AST-Scan ueber app/, components/, hooks/ und lib/, der einen
User je Rolle simuliert und Container meldet, die Chrome ohne Inhalt
rendern: vorher drei Treffer fuer readonly, danach keiner fuer readonly,
editor und admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:13:09 +02:00
vulncheckandClaude Opus 5 a8961ec8fe fix(assets): der Intune-Sync warf namensgleiche Geraete weg
`_dedupe_reenrolled` fasst alle managedDevices mit demselben deviceName
zusammen, bevor irgendetwas ein Asset anfasst. Fuer den Fall, fuer den die
Funktion geschrieben wurde, ist das richtig: ein gewischtes und neu
angemeldetes Geraet steht zweimal in Intune, beide Eintraege matchten dasselbe
Asset und pinnten abwechselnd ihre eigene device id darauf — der Lauf endete
auf der TOTEN Anmeldung und las deren eingefrorene App-Liste.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:54:12 +02:00
vulncheckandClaude Opus 5 1807ea6086 refactor(ui): Refresh Threat Intel ist ein Data Job, kein Filter
Der Button sass als einziger seiner Art rechtsbuendig in Zeile 1 der
Filterleiste, waehrend die neun anderen On-Demand-Laeufe im collapsed
"Data jobs"-Drawer lagen. Funktional ist er genau dasselbe: handleEnrichAll
POSTet /vulnerabilities/enrich/bulk, gleiches Muster wie Exploit Intel oder
MSRC Enrich. Unterschieden hat ihn nur die Haeufigkeit, nicht die Art.

Jetzt steht er als erster Eintrag im Drawer und behaelt tv-btn--primary,
bleibt also der auffaellige der Gruppe. Zeile 1 traegt nur noch Clear und
enthaelt damit ausschliesslich, was die Abfrage aendert - die Regel aus
9f57568, die fuer diesen einen Button nicht durchgezogen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:32:13 +02:00
vulncheckandClaude Opus 5 9f57568caf refactor(ui): die Filterleiste war ein Topf, Buttons tragen wieder nur Text
Die Vulnerabilities-Leiste war ein einziger flex-wrap-Container: Suche,
Selects, Chips, EPSS-Feld und Aktionen sind je nach Fensterbreite quer
durcheinandergerutscht. Jetzt Zeile 1 = die Abfrage (Suche, Severity,
Status, Host) mit Clear und Refresh Threat Intel rechtsbuendig, Zeile 2 =
Verfeinerung (Source, EPSS, Katalog-Chips) auf einheitlicher Hoehe.

Dazu der Rest der Regel aus 91260b9, die nur halb durchgezogen war:
Emoji und Glyphen raus aus Buttons (Configure, Exposure, Notify All,
recompute), die handgebauten Spinner-Glyphen benutzen den vorhandenen
InlineSpinner, und Heroicons neben einem Textlabel entfallen (Export CSV,
Create Group, Logout, Sync Data ...). Icon-only-Buttons mit sr-only oder
title bleiben, die tragen ihre Bedeutung im Icon.

Exposure und Configure tragen jetzt tv-btn statt eigener Lila- bzw.
Ring-Klassen: eine Farbe bedeutet hier Zustand, nicht Dekoration.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:53:23 +02:00
vulncheckandClaude Opus 5 af4863f681 refactor(auth): dieselbe Schale wie Settings, drei Sektionen statt einer Seite
Die Seite war zentriert (max-w-5xl mx-auto), Settings ist linksbuendig
mit Seitenleiste — deshalb sah sie weiter fremd aus, obwohl die Karten
darin schon passten. Jetzt exakt dieselbe Schale: max-w-7xl, linke
Leiste mit den Sektionen, Inhalt in max-w-4xl daneben.

Und wie in den Settings ist immer nur eine Sektion sichtbar statt aller
drei Bloecke untereinander: Overview (Global + Hinweis), Providers,
Role mapping. Der Mapping-Editor ist damit ein Klick entfernt statt
zwei Bildschirme Scrollen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRCKYPm4DQ2vwDkNugGvHK
2026-09-10 10:32:37 +02:00
vulncheckandClaude Opus 5 91260b9336 refactor(ui): ein Loader, ein Dialog, keine Emoji
Drei Sachen, die alle dasselbe Problem hatten: jede Seite loeste sie
anders.

Loader: der Ring mit dem Logo aus dem Dashboard ist jetzt <PageLoader>
und ersetzt "Loading Assets...", "Loading Groups...", "Loading
Advisories...", "Loading compliance data…" und die Suspense-Fallbacks.
motion-reduce bleibt drin — ein Spinner ist Dekoration, nicht Information,
das Label traegt die Bedeutung allein.

Dialoge: alert() / confirm() / prompt() des Browsers sind durch einen
eigenen Dialog im App-Design ersetzt, 74 Aufrufe in zehn Dateien.
Bewusst eine imperative Modul-API (dialog.confirm(...)) und kein Hook:
die Aufrufe stecken verstreut in gewoehnlichen Event-Handlern, und einen
Context durch jeden davon zu faedeln waere ein deutlich groesserer
Eingriff als das Problem wert ist. Ohne gemountetes <DialogHost/> faellt
die API auf das native Fenster zurueck — ein confirm, das nie fragt,
wuerde sonst klaglos loeschen.

Destruktive Aktionen bekommen dabei den roten Button und ein Verb statt
"Confirm" ("Delete asset", "Reset MFA"), damit die gefaehrliche Wahl
nicht die ist, die man im Reflex trifft.

Emoji: die sieben Integrationskarten in den Settings und die
Data-Jobs-Buttons unter Vulnerabilities trugen Emoji als Icons. Raus.

Der <PageLoader> uebernimmt den Ring-um-die-Marke, der im Working Tree
schon fertig lag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRCKYPm4DQ2vwDkNugGvHK
2026-09-10 10:25:36 +02:00
vulncheckandClaude Opus 5 d18953a2ec refactor(auth): Provider-Uebersicht im Muster der Settings-Seite
Vier verschieden hohe Karten in einem 2x2-Raster, jede mit einem
vollbreiten blauen Button — die Seite sah aus wie ein anderes Produkt als
der Rest der App. Jetzt dieselbe Card + divide-y-Liste wie die
Integrations-Uebersicht in den Settings, ein neutraler Test-Button pro
Zeile, Global als Definitionsliste.

Statt zwei Badges pro Provider nur noch einer. "Disabled + Incomplete" in
Orange las sich wie ein Fehler auf jedem Provider, den nie jemand
eingerichtet hat; die einzige echte Fehlkonfiguration ist ein Provider,
der eingeschaltet aber nicht konfiguriert ist — nur der bekommt jetzt
Warn-Farbe. Ein fehlender Fernet-Key ist danger, weil dann keine Secrets
gespeichert werden koennen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRCKYPm4DQ2vwDkNugGvHK
2026-09-10 10:25:21 +02:00
vulncheckandClaude Opus 5 e8e4707f8e feat(ui): Farbe bedeutet Zustand — Token-Schicht statt Regenbogen
Die Vulnerabilities-Seite trug auf neun Job-Buttons, sieben Filtern und
einem Dutzend Row-Badges je einen eigenen Farbton (blau/amber/lila/rot/
orange/emerald/sky/cyan/gelb). Wenn alles schreit, hoert man nichts: das
Rot von KEV konkurrierte mit dem Lila von "EOL Check".

Statt Hexcodes zu tauschen erst eine Token-Schicht in OKLCH (Neutral /
Brand / Semantisch, Werte fuer light + mid + dark), dann die Komponenten
darauf umgestellt. Farbwechsel ist ab jetzt eine Token-Aenderung.

Die Regel dahinter, dokumentiert in DESIGN.md: ein Farbton bedeutet
Zustand, sonst nichts.

- Job-Buttons neutral, genau ein Brand-Button (Refresh Threat Intel).
- Filter sind neutrale Pills und faerben sich nur, wenn sie greifen —
  ueber :has(input:checked), ohne JS. Informativer als vorher, weil man
  jetzt sieht, welche Filter aktiv sind.
- Badges nur noch danger/warn/ok/info. Was bloss eine Identitaet nennt
  (Scanner-Name, Paketname, PoC-Zaehler), bleibt neutral. MSF und SSVC
  "widespread" behalten die einzige Vollton-Flaeche, sie sind die
  hoechste Stufe. Jedes Tag traegt weiterhin seinen Text, damit der
  Zustand nicht allein an der Farbe haengt.
- Die acht Data-Jobs sind keine Filter und standen mitten in der
  Filterzeile — jetzt in einem zugeklappten <details>.

Die .tv-*-Klassen sind ausschliesslich aus Tokens gebaut und laufen
dadurch in allen drei Themes ohne Eintrag in der Utility-Remap-Schicht.
Das ist der Weg, die Schicht langfristig loszuwerden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRCKYPm4DQ2vwDkNugGvHK
2026-09-10 10:01:34 +02:00
vulncheckandClaude Opus 5 174ff72bab fix(settings): der Block sass in der Mitte der leeren Haelfte
Der AppShell schiebt den Inhalt mit lg:pl-72 schon an der dunklen
Seitenleiste vorbei. Das max-w-7xl mx-auto der Seite zentrierte ihn
danach ein zweites Mal -- innerhalb dessen, was uebrig war. Auf einem
breiten Monitor steht der Block damit weit rechts, mit einer leeren
Bahn zwischen Menue und Settings. Ohne mx-auto beginnt er dort, wo die
Seitenleiste aufhoert.

Dazu die zugeklappten Karten enger: p-6 und mt-8 waren fuer eine offene
Karte gedacht, zugeklappt steckt da nur eine Titelzeile drin. p-5, mt-3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:53:34 +02:00
vulncheckandClaude Opus 5 ff89861392 fix(shell): Logout blieb unerreichbar, wenn die Seitenleiste zu lang wurde
Der Logout-Knopf sitzt unten in der dunklen Seitenleiste, per mt-auto ans
Ende geschoben. Nur fehlte dem Desktop-Container das overflow-y-auto, das
die mobile Variante hat: passen Navigation, Theme-Schalter, Benutzerkarte
und Knopf nicht mehr auf die Bildschirmhoehe, laeuft der Knopf unten
hinaus und ist nicht mehr scrollbar. Auf einem 13-Zoll-Display reicht das
Menue dafuer aus.

Dazu ein zweiter Logout dort, wo man ihn zuerst sucht: auf der eigenen
Profilkarte in den Settings, unter E-Mail und Rolle.

Beide rufen jetzt lib/auth.ts:logout() auf, statt die Abfolge "Cookie
widerrufen, dann weiterleiten" zweimal zu fuehren -- ein Logout, der nur
an einer Stelle die Session verwirft, ist genau der Fehler, den man erst
im Log sieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:50:35 +02:00
vulncheckandClaude Opus 5 1b2bf47338 fix(settings): Formularbreite, Zeilenhoehe und die Modal-Hintergruende
Drei Dinge, die die Seite unruhig aussehen liessen:

Die Inhaltsspalte lief bis zum Rand von max-w-7xl. Bei einer Zeile aus
Label links und Schalter rechts heisst das: Beschreibung ueber tausend
Pixel, Schalter am anderen Ende des Bildschirms. Jetzt max-w-4xl, damit
die Bedienelemente bei ihrem Label bleiben.

Die 17 Listenzeilen standen auf py-5 und waren damit fast so hoch wie
breit. py-4 reicht.

Und die elf Modals: Tailwind v4 hat bg-opacity-* ersatzlos gestrichen,
also stand hinter jedem Fenster kein durchscheinender Schleier, sondern
eine deckende graue Wand. Jetzt bg-gray-900/60 mit backdrop-blur-sm; die
Panels bekommen die gleichen eckigen Kanten wie die Karten der Seite.

Dieselbe tote bg-opacity-Klasse steckt noch an sechs Stellen in
page.tsx, scans, groups und assets -- nicht Teil dieser Aenderung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:45:11 +02:00
vulncheckandClaude Opus 5 00855e3de0 feat(settings): die sieben Integrationskarten klappen zu
Auch mit der Sektions-Navigation blieb Integrations die laengste Seite:
sieben Konfigurationskarten mit allen Feldern gleichzeitig offen. Jetzt
sind es native <details>-Karten, zu und mit Titel, Status-Badge und
Chevron -- man sieht auf einen Blick, was konfiguriert ist, und klappt
nur die auf, die man anfassen will.

Kein State, kein Effekt, kein JavaScript: <details>/<summary> bringt
Tastaturbedienung und Screenreader-Semantik mit. Die Trennlinie unter
dem Titel erscheint nur im offenen Zustand, deshalb braucht ihre
Variant-Klasse in globals.css den gleichen Border-Remap wie
border-gray-100, sonst leuchtet sie im Mid-/Dark-Theme hell auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:34:27 +02:00
vulncheckandClaude Opus 5 e465d38f80 feat(settings): eine Sektion sichtbar statt fuenfzehn Karten am Stueck
Die Settings-Seite stapelte alle Karten in einer Spalte: General, User
Management, Integrations Status, MFA, Notifications, dazu sieben
Integrations-Karten und das Profil. Wer die Syslog-Konfiguration wollte,
scrollte an allem anderen vorbei.

Jetzt links eine klebende Sektions-Navigation, rechts genau eine Sektion:
General, Users, Security, Notifications, Integrations, Profile. Die
Rollenpruefung bleibt unveraendert -- die Nav blendet die Admin-Sektionen
nur zusaetzlich aus und faellt auf die erste sichtbare zurueck, solange
/auth/me noch laeuft. Der aktive Tab haengt im URL-Hash, damit ein Reload
oder ein Lesezeichen dort landet, wo man war.

Auf schmalen Viewports wird die Navigation zu einer horizontal
scrollenden Leiste ueber dem Inhalt.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:10:51 +02:00
vulncheckandClaude Opus 5 394a957899 fix(assets): /assets/names in die Readonly-Allowlist
Die Gate-Inventur listet, was ein Readonly-Nutzer erreichen darf, und der neue
Endpoint fehlte darin. Er gehoert hinein: ein Readonly-Nutzer filtert nach Host,
also muss der Picker fuer ihn genauso offen sein wie die Liste, die er ersetzt.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bJSiCMEPYQYdi6emTDRj2
2026-09-09 11:23:50 +02:00
vulncheckandClaude Opus 5 2b4cf2f78f docs(netdisco): Testertext um den Extreme-Teil erweitert
Beide Fassungen. Der Punkt, der oben steht und nicht im Nebensatz: ob Netdisco
die EXOS-Zeile so ausliest, wie die Zuordnung es annimmt (vendor "extreme", os
"xos", Version aus dem sysDescr), ist von hier aus nicht pruefbar - das braucht
ein echtes Geraet. Der Testertext sagt das ausdruecklich, nennt die Herkunft
der Annahme (SNMP::Info Layer3::Extreme) und macht daraus einen konkreten
Handgriff: Asset-OS muss "ExtremeXOS" heissen und die Version blank sein, sonst
die deviceinventory-Zeile schicken. Sonst sieht "keine Findings" aus wie
"sauber".

Dazu die zwei Dinge, die beim Testen sonst als Fehler gelesen wuerden: dass
Extreme im Gegensatz zu Cisco eine Fix-Version angibt (verglichen statt exakt
nachgeschlagen), und dass die alten EXOS-CVEs sofort kommen, die 2026er aber
erst nach dem Indexbau - NVD hat fuer die noch keine CPE-Eintraege.

VOSS/ERS steht als eigener Pruefpunkt drin, nicht nur unter "bewusst nicht
drin": dort kann nur etwas FALSCHES auftauchen, und genau das soll jemand mit
echten Geraeten gegenpruefen.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:47:10 +02:00
vulncheckandClaude Opus 5 a65747d365 docs(netdisco): Slack-Fassung als Nachricht an den Kollegen
Die Slack-Datei war eine unpersoenliche Ankuendigung; sie geht aber an den
Kollegen, der die Cisco-Infos geschickt hat. Jetzt als Antwort formuliert:
sein Geraet und seine CVE-Listen namentlich, die Bestaetigung dass seine
Vorgabe (nur NVD und CVEListv5, Cisco-Advisories aussen vor) genau so
umgesetzt ist — samt dem Beleg, dass sie richtig war: seine IOS-XR-Charge
existiert bei NVD noch gar nicht.

Dazu, was ihn beim Testen sonst irritieren wuerde: keine Fix-Version am
Finding und warum, der Indexbau vor dem ersten Sync (XE sofort, XR erst
danach), und der "os x"-Nebenbefund als eigener Pruefpunkt.

Die ausfuehrliche TESTERTEXT.md bleibt unpersoenlich — die ist fuer alle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:26:36 +02:00
vulncheckandClaude Opus 5 ec7572ddd0 docs(netdisco): Testertext um den Cisco-Teil erweitert
Beide Fassungen auf den Cisco-Stand gezogen: warum bei IOS XE/XR exakt
nachgeschlagen statt verglichen wird (beide Quellen zaehlen die Releases
einzeln auf, 267 bzw. 268 fuer CVE-2026-20267), was daraus folgt (keine
Fix-Version am Finding, ein nicht gelistetes Release wird nicht gemeldet),
warum klassisches IOS draussen bleibt, der "os x"-Nebenbefund, und der
Hinweis auf den Indexbau — v33 heisst, dass der erste Sync nur die
NVD-Haelfte macht, was bei IOS XR sichtbar ist und bei IOS XE nicht.

Testpunkte auf 13 erweitert: der Router aus dem Feldbericht mit seiner
erwarteten CVE-Liste, ein Gegentest auf einem anderen Rebuild derselben
Zeile (die Listen muessen sich unterscheiden), keine macOS-CVEs auf
Netzwerkgeraeten, und die dritte Offline-Suite.

Keine neue Migration — 052 bleibt head; steht jetzt oben, damit niemand
danach sucht.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:19:27 +02:00
vulncheckandClaude Opus 5 11172958f8 docs(netdisco): kein zweites alembic upgrade direkt nach "up -d"
entrypoint.sh migriert bei jedem Containerstart. Ein manuelles
"alembic upgrade head" unmittelbar danach laeuft gegen denselben Schritt:
beide lesen 050, einer schreibt 051, der andere findet beim UPDATE auf
alembic_version keine Zeile mehr und bricht mit "expected to match one row"
ab — obwohl die Migration angewandt ist. Anleitung prueft jetzt mit
"alembic current" statt nochmal zu migrieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:19:31 +02:00
vulncheckandClaude Opus 5 ea338d4e98 docs(netdisco): Testertext fuer den Netdisco-Connector
Beide Fassungen auf das aktuelle Update gezogen: was die Karte kann, welche
drei Aruba-Familien erkannt werden und warum sie getrennt bleiben muessen,
was bewusst fehlt (Bulletins, EoM, Nicht-Aruba), der Apple-"ios"-Nebenbefund,
und neun Testpunkte samt der beiden Offline-Suites.

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

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

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

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

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

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

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

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

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

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

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

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

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

Three parts, all needed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two reasons, both invisible from the outside:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified against all 198 live notices (parser and prototype agree on every
page) and by tests/test_igel_isn.py.
2026-09-03 12:19:16 +02:00
166 changed files with 21592 additions and 1751 deletions
+8
View File
@@ -115,6 +115,14 @@ DASHBOARD_URL=http://localhost:3003
# Optional comma-separated fallback models (route=fallback):
# OPENROUTER_FALLBACKS=openrouter/auto,anthropic/claude-sonnet-4.6
# --- Jev triage hint (optional, off by default) ---
# Nightly 06:00: asks TypeSafe Jev per open CVE whether it only applies with a
# non-default configuration; shown on the Patch Plan. Needs a key: a TypeSafe
# key goes straight to api.typesafe.ai, else OPENROUTER_API_KEY above is used.
# JEV_TRIAGE_ENABLED=true
# TYPESAFE_API_KEY=...
# Model pinned per endpoint: jev-1.13.0 (TypeSafe) / typesafe/jev-1.13 (OpenRouter).
# =============================================================================
# Multi-Provider Authentication (LDAP / OIDC / SAML / TOTP-MFA)
+2 -1
View File
@@ -186,8 +186,9 @@ SSO/LDAP login. `RoleMapper` re-evaluates role on every login from
| `sla_breach_check` | every 1 h | SLA-overdue scan; honors `sla_breach_enabled` toggle + `PolicyStatus.DISABLED` skip; digest or single mode |
| `threat_intel_refresh` | every 24 h | Refreshes EPSS, KEV, EUVD across all open vulns; runs `kev_alert_check` straight after, since the catalogs just moved |
| `kev_alert_check` | hourly, :25 | Mails actively-exploited CVEs (sources per `kev_alert_sources`, default CISA KEV + ENISA EUVD) that have OPEN findings on active assets; honors `kev_alert_enabled`, idempotent via `kev_alert_state` |
| `vuln_index_refresh_nightly` | 01:30 UTC | Rebuilds the cvelistV5 (+MFSA), GitHub-advisory, TeamViewer and IGEL-ISN indexes before the Intune/Defender, vCenter and IGEL syncs read them |
| `compliance_sca_nightly` | 02:00 UTC | Wazuh SCA pull for every linked asset |
| `intune_sync_nightly` | 02:10 UTC | Intune managed devices → assets + OS-EOL |
| `intune_sync_nightly` | 02:10 UTC | Intune managed devices → assets + OS-EOL + per-device app-CVE scan, Defender TVM CVEs when enabled |
| `vcenter_sync_nightly` | 02:20 UTC | vCenter + ESXi hosts → assets, EOL, vSphere CVEs (refreshes the build catalog first) |
| `igel_sync_nightly` | 02:30 UTC | IGEL UMS server + endpoint devices → assets, IGEL OS CVEs |
| `vulnrichment_nightly` | 03:00 UTC | 3-stage CVSS/SSVC/fixed_version cascade |
+65
View File
@@ -0,0 +1,65 @@
# TrueVuln — Design-Tokens
Alle Farben liegen als CSS-Variablen in `frontend/app/globals.css`.
Farbwechsel = Token-Werte ändern, nichts anderes. Keine neuen Hex-Werte
in Komponenten.
Format ist **OKLCH** (`L C H`): wahrnehmungsgleichmäßige Helligkeit, dunkle
und helle Stufen verlieren die Sättigung nicht. Chroma bleibt für UI unter
~0.22, Neutrale unter 0.02.
## Die Regel
Drei Gruppen, und nur die Zuordnung entscheidet — nicht der Geschmack:
| Gruppe | Wofür | Tokens |
|---|---|---|
| **Neutral** | Der Großteil der UI: Flächen, Text, Border, alles was nur *benennt* | `--tv-fg`, `--tv-fg-soft`, `--tv-fg-muted`, `--tv-line`, `--tv-line-strong`, `--tv-btn-bg`, `--tv-mute-*` |
| **Brand** | Genau eine Sache: die primäre Aktion und der aktive Filter | `--tv-brand`, `--tv-brand-solid`, `--tv-brand-hover`, `--tv-brand-soft`, `--tv-brand-ring` |
| **Semantisch** | Nur **Zustand** | `--tv-danger*`, `--tv-warn*`, `--tv-ok*`, `--tv-info*` |
**Ein Farbton bedeutet Zustand, sonst nichts.** Ein Label, das nur eine
Identität nennt (Scanner-Name, Paketname, PoC-Anzahl), bleibt neutral —
sonst konkurriert es mit dem Rot, das wirklich zählt. Vorher trug jeder
Job-Button und jeder Filter eine eigene Farbe; dadurch stach nichts mehr
hervor.
Zustände nie nur über Farbe: jedes Tag trägt zusätzlich seinen Text
(`KEV`, `PATCH AVAILABLE`, `TI-TOTAL`), damit es auch bei Farbfehl-
sichtigkeit lesbar bleibt.
## Semantik-Zuordnung (Vulnerabilities)
| Token | Bedeutung | Beispiele |
|---|---|---|
| `--tv-danger` | akute Bedrohung / offen | CRITICAL, KEV, RANSOM, TI-TOTAL, EXPLOITABLE, Status `open`, CPR/Priority-Balken hoch |
| `--tv-danger-solid` (`.tv-tag--alarm`) | eine Stufe darüber: waffenfähig | MSF, SSVC `widespread` |
| `--tv-warn` | erhöht, aber nicht akut | HIGH, EDB, AUTO, EU-CRIT, VPR ≥ 7, SSVC `active` |
| `--tv-ok` | erledigt / bestätigt gut | PATCH AVAILABLE, Status `patched`, cross-confirmed |
| `--tv-info` | Einordnung ohne Dringlichkeit | MEDIUM, EUVD, EOL, aktiver Suchbegriff |
| neutral | reine Identität | Scanner-Namen, Paketnamen, PoC-Zähler, `deferred`, LOW, NON-CVE |
## Komponenten-Klassen
`.tv-btn` / `.tv-btn--primary`, `.tv-chip` (+ `.tv-chip__box`), `.tv-tag`
(+ `--danger` `--warn` `--ok` `--info` `--alarm` `--lg`), `.tv-bar`
(+ `__hi` `__mid` `__low`), `.tv-pop`, `.tv-drawer`.
Sie sind ausschließlich aus Tokens gebaut und funktionieren dadurch in
allen drei Themes (`light` / `mid` / `dark`) ohne Eintrag in der
Utility-Remap-Schicht darüber. Neue UI bitte damit bauen, nicht mit
`bg-red-100 text-red-700`-Ketten — das ist der Weg, die Remap-Schicht
langfristig loszuwerden.
Der aktive Zustand eines Filter-Chips kommt aus `:has(input:checked)`
kein JS, kein State-Prop.
## Themes
`:root` = light. `html[data-theme="mid"|"dark"]` überschreiben nur die
Token-Werte: Neutrale hängen sich an die vorhandene Surface-Skala,
semantische Flächen werden über `color-mix(… , transparent)` zu
Tints (eine Pastellfläche würde auf dunklem Grund ein Loch reißen),
und die Vordergrundfarben steigen in der Helligkeit, damit AA bleibt.
`--tv-brand-solid` bleibt in beiden Modi dunkel genug für weißen
Button-Text.
+11 -16
View File
@@ -7,29 +7,24 @@ RUN useradd -m -u 1000 vulnmanager
WORKDIR /app
# Install system dependencies.
# - postgresql-client: alembic / psql tooling
# - postgresql-client: pg_isready in the entrypoint
# - dos2unix: entrypoint line-endings
# - libxml2-dev, libxmlsec1-dev, libxmlsec1-openssl, pkg-config:
# required by python3-saml (XML signature validation)
# - libsasl2-dev, libldap2-dev, libssl-dev:
# required by python-ldap if used (ldap3 is pure-python so optional,
# kept here for future flexibility)
RUN apt-get update && apt-get install -y --no-install-recommends \
# No -dev packages: every requirement ships a wheel (xmlsec and lxml bundle
# their own libxml2), and the headers only added CVE findings to the image
# (linux-libc-dev, libunbound, libxml2). upgrade: the base image lags behind
# Debian security updates.
RUN apt-get update && apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
postgresql-client \
dos2unix \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
pkg-config \
libsasl2-dev \
libldap2-dev \
libssl-dev \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
# Copy requirements and install Python dependencies. --only-binary: a
# requirement without a wheel fails the build instead of needing a compiler.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
&& pip install --no-cache-dir --only-binary=:all: -r requirements.txt
# Copy application code (and entrypoint)
COPY . .
+941 -6
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -125,6 +125,7 @@ stay uncorrected until the following night.
| Time | Job | Stage |
|------|-----|-------|
| 01:30 | Vulnerability index refresh (cvelistV5 + MFSA, GitHub advisories, TeamViewer, IGEL ISN) — before every sync that decides from them | catalog |
| 02:00 | Wazuh SCA compliance refresh | inventory |
| 02:10 | Intune inventory sync | inventory |
| 02:30 | Network exposure + risk dimensions | inventory |
@@ -191,7 +192,9 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
|---|:---:|:---:|:---:|
| **View** vulnerabilities, assets, reports, dashboards | ✅ | ✅ | ✅ |
| **View/download** reports (Executive, Technical CSV, ISO 27001, Patching) | ✅ | ✅ | ✅ |
| **View** scan history | | ✅ | ✅ |
| **View** scan history / sync health (Scan Jobs) | | ✅ | ✅ |
| **View** SLA policies (Policies page) | ❌ | ✅ | ✅ |
| **View** group membership (Groups page) | ❌ | ❌ | ✅ |
| **View** notification history | own only | own only | ✅ all |
| **View** AI analysis history | ✅ | ✅ | ✅ |
| **Edit** vulnerabilities (status, assign, defer, reopen) | ❌ | ✅ | ✅ |
+397
View File
@@ -0,0 +1,397 @@
# TrueVuln — Update auf `dev` (Netdisco-Connector: Switche & Router als Assets, Aruba-, Cisco- und Extreme-Firmware-CVEs)
```bash
git pull
docker compose build
docker compose up -d
docker compose exec backend alembic current # erwartet: 052 (head)
```
**Migration noetig** (052) — die laeuft aber schon beim Containerstart
(`entrypoint.sh`), deshalb oben nur die Kontrolle. Ein zusaetzliches
`alembic upgrade head` direkt nach `up -d` rennt gegen den Start-Lauf und
bricht mit *"expected to match one row when updating '050' to '051'"* ab; die
Migration ist dann trotzdem durch, nur eben im anderen Prozess.
**Weder der Cisco- noch der Extreme-Teil bringt eine neue Migration mit**
052 bleibt head. Wer die Netdisco-Karte schon getestet hat, muss also nur bauen
und neu starten.
Backend und Frontend.
---
## Was neu ist
Unter **Settings → Netdisco (switches & routers)** gibt es einen Connector wie
den fuer vCenter und IGEL. Er liest den `deviceinventory`-Report der Netdisco-API
und legt **jedes dort bekannte Geraet** als Asset an — mit Hostname, IP,
Standort, Modell, Seriennummer und Firmware-Version. Fuer sechs Firmware-Linien
werden daraus CVE-Findings: die drei HPE-Aruba-Linien, Cisco IOS XE / IOS XR und
**neu Extreme Networks EXOS (Switch Engine)**.
Warum ueberhaupt: ein Switch laeuft ohne Agent, keine MDM kennt ihn, und ein
Nessus-Scan ohne SNMP-Zugangsdaten sieht einen offenen Port und keine Version.
Die Geraete, an denen alles andere haengt, waren damit die einzigen ohne jede
Abdeckung — nicht "sauber", nur nie angeschaut. Netdisco fragt sie ohnehin per
SNMP ab und kennt die Firmware.
## Konfiguration
| Feld | Bedeutung |
|---|---|
| Host / Port | Default `5000` — der Netdisco-eigene Webserver |
| HTTPS | aus per Default: der eingebaute Webserver kann **kein** TLS. Anhaken, wenn ein Reverse-Proxy davor steht (dann auch den Port anpassen) |
| Verify SSL | nur mit HTTPS relevant — aus, wenn der Proxy ein internes CA-Zertifikat traegt |
| API-Key | empfohlen. Wird als `Authorization: <key>` geschickt, ohne `Bearer` |
| Username / Passwort | Alternative: wird einmal an `POST /login` geschickt, das genau so einen Key zurueckgibt |
Key **oder** Zugangsdaten reichen — es ist dasselbe Verfahren, nur von zwei
Seiten: der Login *erzeugt* den Key. Beides leer laeuft auch, aber nur fuer eine
Instanz ganz ohne Authentifizierung (die oeffentliche Demo). Produktiv gehoert
ein Key rein.
Nightly um **02:40 UTC**, direkt nach dem IGEL-Sync und vor dem App-CVE-Scan um
03:20. "Test" und "Sync now" wie bei den anderen Karten; der Lauf landet im
Sync-Ledger unter **Scan Jobs**.
> Der Test meldet *"N devices, M of them on a firmware line with CVE
> coverage"* — vorher stand da "of them Aruba firmware". Dieselbe Zahl, nur
> zaehlt sie ab sofort auch die Cisco- und Extreme-Geraete mit.
## Cisco IOS XE / IOS XR — exakt statt verglichen
Das ist der inhaltlich interessante Teil, und er funktioniert **anders als alles
andere im Dashboard**: hier wird nicht verglichen, sondern nachgeschlagen.
Cisco veroeffentlicht fuer diese Produkte **keine Ranges**. Beide Quellen zaehlen
die betroffenen Releases einzeln auf:
| Quelle | CVE-2026-20267 (IOS XE Hardening, Aug 2026) |
|---|---|
| NVD | 267 einzelne `cpeMatch`-Eintraege, exakt `cpe:2.3:o:cisco:ios_xe:17.15.4c`, nirgends ein `versionStart`/`versionEnd` |
| CVEListv5 | 268 blanke `version`-Eintraege, `defaultStatus: unknown` |
"Betroffen" ist damit Mengenzugehoerigkeit. Vergleichen waere hier nicht
ungenauer, sondern schlicht falsch: Cisco liefert `17.15.4`, `.4a`, `.4b`, `.4c`
und `.4d` als **fuenf verschiedene Releases** aus, und ein Record nennt einige
davon und andere nicht — als Zahl gelesen sind alle fuenf `(17,15,4)`. Ein
Router auf 17.15.4c haette so jede CVE mitbekommen, die nur `17.15.4` nennt.
Drei Konsequenzen, die beim Testen auffallen werden:
* **Es steht keine Fix-Version am Finding.** Ein Cisco-Record listet, was kaputt
ist, und sagt nichts darueber, was es nicht ist — die "fixed in 17.15.6" steht
nur auf Ciscos Advisory-Seite, und die ist bewusst nicht angebunden (siehe
unten). Das Finding hat trotzdem seinen Weg zurueck: sobald das Geraet auf ein
Release geht, das der Record nicht auffuehrt, trifft nichts mehr und die
Reconcile schliesst es.
* **Ein nicht gelistetes Release wird nicht gemeldet.** `defaultStatus` ist
`unknown`, nicht `unaffected` — was Cisco nicht aufgezaehlt hat, hat Cisco
nicht beurteilt, und das lesen wir als "kein Verdikt". Ein brandneuer Rebuild
wird also eher zu wenig gemeldet als falsch. NVDs eigene CPE-Daten
entscheiden das genauso.
* **Gross-/Kleinschreibung und fuehrende Nullen sind egal.** Cisco schreibt
denselben Build als `17.09.05a` und `17.9.5a`, und `3.2.0SG` heisst in der CPE
`3.2.0sg`. Beide Haelften normalisieren ueber dieselbe Funktion, damit sie
nicht auseinanderlaufen.
**Beide Quellen tragen, und keine ist ueberfluessig.** Stand 08.09.2026: die
IOS-XE-Charge (CVE-2026-20267…20273) ist bei NVD vollstaendig angereichert,
waehrend die IOS-XR-Charge einen Monat spaeter (CVE-2026-20274…20280) dort noch
auf *"Awaiting Analysis"* mit **null** CPE-Eintraegen steht und strukturiert nur
in Ciscos eigenen Records existiert. Umgekehrt gibt es auch: CVE-2019-12660
schreibt sein betroffenes Release in den **Produktnamen**
(`"Cisco IOS XE Software 3.2.11aSG"`, Version `"unspecified"`) — die kennt nur
NVD.
Die Produkt-Anker sind hier wichtiger als irgendwo sonst: Cisco legt **jedes**
Produkt unter einem Hersteller ab und schreibt sie als getrennte Bloecke in
**denselben** Record. CVE-2025-20363 fuehrt fuenf davon — IOS (2005 Versionen),
IOS XR (13), IOS XE (456), ASA (220) und Firepower (94). Lose gematcht bekaeme
ein Router die Release-Liste einer Firewall.
## Extreme Networks EXOS — gefloorte Ranges, und was ich NICHT pruefen konnte
Extreme ist von den dreien der einfache Fall: Versionen sind blanke Zahlen
(`31.7.2.4`), und Extreme schreibt saubere Ranges — **jede mit eigener
Untergrenze**. CVE-2026-8169 fuehrt vier davon in einem Record:
| ab Version | betroffen bis unter |
|---|---|
| `0` | `31.7.4` |
| `32.0.0` | `32.7.4.15` |
| `33.0.0` | `33.1.100` |
| `33.2.0` | `33.7.1` |
`defaultStatus` ist hier `unaffected` (anders als bei Cisco) — ein Release
ausserhalb aller vier ist also eines, das Extreme geprueft und freigegeben hat.
Der Aruba-Branch-Guard laeuft hier bewusst **nicht** mit: die erste Zeile mit der
Untergrenze `0` ist Extremes eigene Aussage "alles unter 31.7.4, egal auf welcher
Linie" — mit Branch-Guard waeren genau die alten Switche rausgefallen, um die es
geht.
**Beide Quellen, und hier traegt NVD die Hauptlast.** Extremes eigene CNA meldet
erst seit 2025. Alles davor — CVE-2023-43119/43120/43121 (Chalet: Directory
Traversal und Rechteausweitung), CVE-2024-27453, CVE-2020-18305 — liegt in
CVEListv5 nur als MITRE-Platzhalter mit `vendor: "n/a"`, also voellig ohne
strukturierte Daten. Umgekehrt standen CVE-2026-8169 und -8170 am 08.09.2026 bei
NVD noch auf *"Awaiting Analysis"* mit **null** CPE-Eintraegen, waehrend Extremes
Record die vier Ranges oben bereits sauber angab.
Dazu eine NVD-Eigenheit, die man kennen muss: **dieselbe Firmware liegt dort
unter zwei Produktnamen**, und die CVEs sind auf beide verteilt. Beide werden
abgefragt (live geprueft am 08.09.2026):
| CPE | enthaelt |
|---|---|
| `cpe:2.3:o:extremenetworks:extremexos` | CVE-2024-27453, CVE-2020-18305, die 2017er-Charge |
| `cpe:2.3:o:extremenetworks:exos` | CVE-2023-43118…43121 |
Unter dem jeweils anderen Namen taucht davon **nichts** auf — mit nur einer der
beiden CPEs waere die Haelfte unsichtbar geblieben.
> **Das ist der Teil, den ich nicht selbst verifizieren konnte** — dafuer braucht
> es ein echtes Geraet. Laut SNMP::Info-Quellcode (`Layer3::Extreme`) meldet ein
> EXOS-Switch Hersteller `extreme` und OS-Slug `xos`, und die Version liest das
> Modul per `/Version\s+([^ ]+)/i` aus dem sysDescr. Genau darauf baut die
> Zuordnung auf. Falls eure Geraete etwas anderes melden, sagt der Test es sofort:
> Asset-OS steht dann nicht als `ExtremeXOS` da, sondern als roher Slug, und der
> Zaehler "on a firmware line with CVE coverage" zaehlt sie nicht mit.
Zwei Dinge werden dabei normalisiert, beide beim Einlesen: der ganze
sysDescr-Satz, falls Netdisco ihn mitliefert
(`ExtremeXOS (X440G2-24t-10GE4) version 31.7.2.4``31.7.2.4`), und der
Patch-Anhang eines Patch-Builds (`22.7.1.1-patch1-11``22.7.1.1`). Den nennt
keine der beiden Quellen je als Grenze — Extreme behebt diese Faelle ueber das
Release der Obergrenze, nicht ueber einen Patch darunter.
## Wie die Aruba-Erkennung entscheidet
Drei Aruba-Firmware-Linien, die HPE alle unter einem Hersteller ablegt und deren
Nummern sich ueberschneiden:
| Asset-OS | Firmware | Geraete |
|---|---|---|
| `ArubaOS-CX` | `10.13.1005` | 6000/6300/8300 |
| `ArubaOS-Switch` | `16.11.0016` | ProVision: 2530, 2930F, 3810, 5400R |
| `ArubaOS` | `8.13.1.1` / `10.7.2.2` | Mobility Controller / Gateways |
Ein CX-Switch auf 10.13.1005 und ein Controller auf 10.7.2.2 sind beide "10.x"
und haben sonst nichts gemeinsam — quer gematcht bekaeme der Switch ein
Controller-Image als Empfehlung. Deshalb: jede Familie mit eigenem Schluessel,
eigenem verankerten Produkt-Regex und eigener NVD-CPE. Welche Familie ein
Netdisco-Geraet ist, muss aus **drei** Angaben zusammenpassen (SNMP-OS-Slug,
Hersteller, Form der Versionsnummer) — sonst wird nichts behauptet. Fuer Cisco
gilt dieselbe Dreier-Regel.
Dazu ein **Branch-Guard**: HPE schreibt pro Record eine Range je Release-Zweig
(CVE-2026-73749 fuehrt 10.18, 10.17, 10.16, 10.13 und 10.10 nebeneinander), also
muss ein Eintrag ohne eigene Untergrenze denselben Zweig (major.minor) haben wie
die installierte Version.
Beide Quellen, wie ueberall hier: NVD hat brauchbare CPE-Ranges fuer die drei
ArubaOS-Switch-CVEs von 2023, waehrend die aktuellen AOS-CX-Faelle
(CVE-2026-73749, -44880) dort unangereichert liegen und nur als HPEs eigene
CNA-Records existieren.
**Die Buchstaben vor der Version sind egal**`WC.`, `YA.`, `PL.` benennen die
Hardware-Linie, nicht die Version. Verglichen wird `16.11.0016`; die
Original-Zeichenkette steht in der Asset-Beschreibung, weil man genau die auf
HPEs Download-Seite wiederfindet.
Bei Cisco ist es **umgekehrt**: die Buchstaben *sind* dort die Version. `17.15.4`
und `17.15.4c` sind zwei Releases, und genau deshalb wird bei Cisco exakt
verglichen und bei Aruba numerisch.
## Bewusst nicht drin
* **Keine Hersteller-Bulletins.** Weder HPEs eigene (hpesbnw*) noch Ciscos
Security Advisories. Sie tragen dieselben CVEs, die die CNA-Records bereits
strukturiert angeben. Bei Cisco haetten sie *eine* Sache mehr — die
Fix-Version —, aber `sec.cloudapps.cisco.com` zu scrapen ist eine dritte
Quelle mit eigenen Ausfallarten. Kommt rein, wenn die fehlende Fix-Version
jemanden tatsaechlich blockiert.
* **Kein EoL/EoM.** Genau das waere fuer einen Switch die wichtigste Meldung —
eine Firmware-Linie ohne Sicherheitspatches ist nicht mehr patchbar. Aber HPE
veroeffentlicht diese Daten pro **Hardware-Produkt** im Support-Portal, nicht
pro Firmware-Linie, und endoflife.date fuehrt ArubaOS ueberhaupt nicht. Ein
erfundenes Datum wuerde echte Assets auf Verdacht stilllegen. Sobald es eine
Quelle gibt, die EoM je OS-Release sagt, ist der Rest bereits da.
* **Extremes Fabric Engine (VOSS) ist nicht dabei — und zwar aus einem
Inventar-Grund, nicht aus Datenmangel.** Die CVEs gibt es (CVE-2025-11192 hat
sowohl CPE als auch CNA-Range). Aber SNMP::Info hat keine VOSS-Klasse: ein VSP
wird als `Layer3::Passport` gefuehrt und meldet OS `passport`, Hersteller
`avaya` — exakt dasselbe wie ein Avaya/Nortel-ERS-Switch, dessen 5.9.x-Firmware
mit Fabric Engine nichts zu tun hat. Auf der Netdisco-Zeile unterscheidet die
beiden nichts, und "vor 9.3" wuerde auf einen ERS mit 5.x losgelassen die
komplette ERS-Flotte melden. Sobald sich die Zeilen unterscheiden lassen, sind
es ein paar Zeilen Arbeit — sagt Bescheid, wenn ihr VSPs im Bestand habt.
* **ExtremeWare** (der alte Slug `extremeware`, Firmware bis 2010) ebenfalls
nicht: andere Produktlinie, und NVDs Grenzen dafuer enden bei 11.1.
* **Klassisches Cisco IOS (12.x/15.x) ist nicht dabei.** Es nummeriert Releases
als `15.2(7)E3`; NVD maskiert die Klammern in der CPE, und kein Versionsschema
hier liest das. So ein Geraet bekommt weiterhin Asset und OS-String
(`cisco ios`) und **kein** CVE-Verdikt. IOS XE und IOS XR sind abgedeckt.
* **Alles Uebrige** (Cumulus, NX-OS, …) bekommt ebenfalls Asset und OS-String
ohne CVE-Verdikt. Jede Familie braucht eigenes kuratiertes Matching; ein
generischer Versionsvergleich ueber Hersteller hinweg ist genau die Art
Scanner, die eine Seite voller falscher Findings produziert.
* **"All versions"-Eintraege** in den ArubaOS-Switch-Records werden nicht
indiziert. Sie haben keine Obergrenze, lassen sich also weder vergleichen noch
durch ein Update innerhalb des Zweigs schliessen — ein Finding ohne
erreichbaren Fix ist Rauschen. Auf so einem Zweig wird eher zu wenig als
falsch gemeldet.
## Nebenbefunde, mitgefixt
Zweimal dieselbe Falle, einmal beim Aruba- und einmal beim Cisco-Update, jeweils
einen Buchstaben weiter:
1. Die Apple-Muster matchten `ios` als **Teilstring**. Netdisco nennt die
Firmware eines Cisco-Switches genau so ("ios", als Asset-OS "cisco ios") —
jedes Cisco-Geraet im Bestand waere gegen Apples iPhone-Ranges geprueft
worden, und IOS 15.2 liegt unter jeder iOS-Grenze, die je geschrieben wurde.
2. Und `"os x"` matchte als Teilstring **in `"cisco iOS XE"`** — da steht es
drin: `i-os -xe`. Jeder Cisco-Router waere als Mac gelesen worden: gegen
macOS-Ranges verglichen (17.15.4c liegt unter jeder macOS-26-Grenze, haette
also getroffen) und gleichzeitig um seine eigenen Cisco-Findings gebracht,
weil der Plattform-Filter sie als "falsche Plattform" verworfen haette.
Beide Muster sind jetzt verankert bzw. wortgrenzen-geprueft; `iOS 18.1`,
`iPadOS`, `iPhone OS`, `Mac OS X 10.15` und `macOS 15.1` treffen weiterhin.
## Nachtrag aus dem Test: Geraete ohne Namen, und der fehlende Quellfilter
Gemeldet mit Screenshot, drei Punkte, davon zwei derselbe Fehler.
**Assets, die "10" oder "192" heissen.** Nicht jedes Geraet antwortet Netdisco
einen Namen — eine AudioCodes-Media-Gateway und ein LANCOM-Router im Testbestand
tun es nicht. Der Sync faellt dann auf die kanonische IP zurueck, hat davon aber
die **Kurzform** genommen, so wie er es bei einem FQDN tut
(`sw-12.example.local``sw-12`). Auf einer Adresse angewandt bleibt davon das
erste Oktett uebrig: `192`. Ab sofort ist ein namenloses Geraet die **volle
IP-Adresse**; eine Kurzform wird nur noch aus einem echten Geraetenamen gebildet.
**Und deshalb auch die vermischte Zeile.** Der abgeschnittene Name war nicht nur
haesslich, er war ein Suchschluessel: das zweite namenlose Geraet im selben /8
fand ueber `hostname = "192"` das Asset des ersten und wurde **daraufgeschrieben**.
Im Screenshot deshalb ein Asset mit LANCOM als Hersteller und der Firmware
`5.4.2.30` eines netgear-Switches — zwei Geraete, eine Zeile. Mit dem vollen
Namen kann das nicht mehr passieren, jede Adresse ist eindeutig.
> Die bereits falsch angelegten Assets muessen **nicht** von Hand aufgeraeumt
> werden: heisst ein Asset genau wie das erste Oktett der IP, an die es gepinnt
> ist, benennt der naechste Sync es auf die volle Adresse um. Das Geraet, das den
> Pin nicht haelt, bekommt dabei sein eigenes Asset. Eine falsche `os_version`
> auf dem geheilten Asset bleibt allerdings stehen, bis dessen eigenes Geraet
> wieder eine Version liefert — ein leerer Wert ueberschreibt nie einen
> vorhandenen. Wem das zu langsam ist: die beiden Zeilen einmal loeschen, der
> naechste Sync legt sie sauber neu an.
**Netdisco fehlte im Quellfilter** ueber der Inventarliste (und damit im
CSV-Export, der genau diesen Filter mitnimmt). Ist jetzt drin. Der Filter geht
dabei ueber die Pin-Spalte, nicht ueber die Quelle, unter der ein Asset einmal
angelegt wurde — sonst faellt jeder Switch raus, den Wazuh oder Nessus zuerst
gesehen hat. Nebenbei: dieselben Switche tauchten vorher unter **"Manual"** auf,
weil sie keine der abgefragten Scanner-Verknuepfungen hatten. Auch das stimmt
jetzt.
## Vor dem Testen: einmal den CVE-Index bauen lassen
Der cvelistV5-Index hat eine neue Version (v34, wegen der Extreme-Produkte —
vorher v33 fuer Cisco). Der **erste** "Sync now" direkt nach dem Update findet
ihn noch nicht und macht dann nur die NVD-Haelfte — im Log steht dann *"no
cvelistV5 index yet — CVE pass deferred to the nightly app-CVE scan"*.
Praktisch heisst das: **IOS-XE-Findings und die aelteren EXOS-Findings kommen
sofort** (NVD hat die Daten), **IOS-XR und die EXOS-CVEs von 2026 erst nach dem
Indexbau** (NVD hat dort nichts). Entweder eine Nacht warten (01:30 UTC) oder
den App-CVE-Scan einmal von Hand anstossen, der baut den Index selbst:
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
"http://localhost:8000/api/v1/vulnerabilities/app-cve-scan"
```
## Bitte testen
1. **Verbindung, drei Varianten.** (a) Demo-Instanz ohne Zugangsdaten
(`netdisco-demo-jyxhs.ondigitalocean.app`, Port 443, HTTPS an) → Test meldet
`6 devices`. (b) Eigene Instanz mit API-Key ueber HTTP:5000. (c) Falls
vorhanden: hinter Reverse-Proxy mit HTTPS. Erwartung jeweils: Geraetezahl und
wie viele davon auf einer Firmware-Linie mit CVE-Abdeckung liegen.
2. **Falscher Key.** Key verstellen → Test meldet klar "rejected (HTTP 401/403)"
bzw. bei einer Login-Seite mit HTTP 200 "no JSON (a login page, most likely)"
— nicht "0 Geraete".
3. **Erster Sync.** "Sync now" → Assets erscheinen im Inventar, Quelle
`NETDISCO`, mit Standort, Modell und Firmware in der Beschreibung.
4. **Die vier Aruba-Switche.** Erwartung: der CX auf `PL.10.13.1005` bekommt zwei
Findings (CVE-2026-73749 und -44880, jeweils mit dem Fix des **10.13**-Zweigs,
nicht dem von 10.17); der CX auf `PL.10.08.1010` bleibt sauber (kein Record
fuehrt einen 10.08-Zweig); beide ProVision-Switche (`WC.16.11.0016`,
`YA.16.11.0027`) bleiben sauber, weil sie ueber den 16.11-Grenzen liegen.
5. **Der Cisco-Router.** Das Testgeraet (C1121X-8PLTEP, `ios-xe`, `17.15.4c`):
Asset-OS steht als **`Cisco IOS XE`** da (nicht als `ios-xe`), Version
`17.15.4c`. Erwartete Findings: **CVE-2026-20267 bis -20273** (die
Hardening-Charge vom August) plus **CVE-2019-12660** und **CVE-2025-20363**.
Ohne Fix-Version — das ist so gewollt, siehe oben.
6. **Der exakte Match, der eigentliche Punkt.** Wenn moeglich ein zweites
IOS-XE-Geraet auf einem *anderen* Rebuild derselben Zeile (z. B. `17.15.4`
oder `17.15.4a`) gegenpruefen: die Finding-Listen der beiden Geraete duerfen
sich **unterscheiden**. Wenn beide identisch sind, obwohl die Releases
verschieden sind, stimmt etwas nicht.
7. **Firmware hochziehen.** Einen betroffenen Switch oder Router aktualisieren
(Cisco: auf ein Release, das in den Records nicht auftaucht, etwa 17.15.6),
Sync → das Finding schliesst sich von selbst (Change History: "at/past the
fix"). Das ist der Punkt, an dem sich zeigt, ob die Erkennung einen Weg
zurueck hat.
8. **Klassisches IOS und der Rest.** Ein Geraet mit OS-Slug `ios` (12.x/15.x):
Asset ja, OS `cisco ios`, aber **keine** Findings — und vor allem keine
Apple- oder macOS-CVEs. Dasselbe fuer Cumulus & Co.
9. **Keine macOS-CVEs auf Netzwerkgeraeten.** Explizit gegenpruefen: auf keinem
`Cisco IOS XE`- oder `Cisco IOS XR`-Asset darf eine macOS- oder iOS-CVE
stehen. Das ist der Nebenbefund von oben.
10. **Die Extreme-Switche — und zuerst: kommt die Zeile ueberhaupt richtig an?**
Nach dem Sync ein EXOS-Geraet im Inventar aufmachen. Erwartung: Asset-OS
steht als **`ExtremeXOS`** da (nicht als `xos` oder `extreme xos`) und die
Version als blanke Zahl (`31.7.2.4`; bei einem Patch-Build `22.7.1.1` statt
`22.7.1.1-patch1-11`). Steht dort etwas anderes, meldet euer Netdisco andere
Slugs als angenommen — dann bitte die Zeile aus dem `deviceinventory`-Report
schicken (`vendor`, `os`, `version`), das ist dann ein Zweizeiler.
11. **Die EXOS-Findings.** Ein Switch unterhalb der Grenzen (alles unter 31.7.4,
oder 32.x unter 32.7.4.15) bekommt **CVE-2026-8169** und **CVE-2026-8170**,
beide **mit** Fix-Version — anders als bei Cisco, hier gibt Extreme sie an.
Aeltere Stände zusaetzlich aus der NVD-Haelfte: ein Switch auf 22.6 etwa
CVE-2023-43119/43120/43121, CVE-2024-27453 und CVE-2020-18305. Ein Switch
auf 31.7.4 oder neuer bleibt bei den 2026ern sauber.
12. **Ein VSP mit VOSS / ein Avaya-ERS**, falls vorhanden: Asset ja, OS
`avaya passport`, **keine** Findings. Das ist Absicht (siehe "Bewusst nicht
drin") — wichtig ist vor allem, dass dort *nichts Falsches* auftaucht.
13. **Umbenennen.** Einen Switch in Netdisco umbenennen, Sync → dasselbe Asset
uebernimmt den neuen Namen, es entsteht **kein** zweites.
14. **Entfernen.** Ein Geraet aus Netdisco loeschen, Sync → Asset wird `inactive`
(mit Audit-Eintrag), nicht geloescht. Ein nur ausgeschalteter oder gerade
nicht erreichbarer Switch bleibt `active`, solange Netdisco ihn noch fuehrt.
15. **Ledger.** Scan Jobs → der Lauf steht als `netdisco` drin, mit Statistik und
im Fehlerfall als `failed` samt Mail. Hinweis: Laeufe von **vor** diesem
Update zeigen in der Historie `0` bei "with firmware CVE coverage" — der
Zaehler wurde umbenannt, alte Eintraege haben den alten Schluessel. Ab dem
naechsten Sync stimmt die Zahl wieder.
16. **Ohne Netdisco pruefbar** (Regeln offline):
```bash
venv/bin/python tests/test_aruba_firmware.py
venv/bin/python tests/test_cisco_firmware.py
venv/bin/python tests/test_extreme_firmware.py
venv/bin/python tests/test_netdisco_inventory.py
venv/bin/python tests/test_netdisco_nameless_device.py
```
17. **Ein Geraet ohne Namen.** Falls im Bestand: Asset heisst jetzt nach der
**vollen** IP (`192.168.1.6`), nicht `192`. Zwei namenlose Geraete = zwei
Assets, jedes mit seinem eigenen Hersteller und seiner eigenen Firmware.
Die alten `10`/`192`-Zeilen aus dem letzten Test: einmal syncen, dann
schauen, ob sie den vollen Namen tragen.
18. **Quellfilter und Export.** Inventar → Filter auf **Netdisco**: es kommen
genau die Netdisco-Geraete, auch die, die Wazuh oder Nessus zuerst
registriert hat. "Export CSV" bei gesetztem Filter enthaelt dieselbe Menge.
Und: unter **Manual** darf jetzt kein Switch mehr stehen.
+26
View File
@@ -0,0 +1,26 @@
Moin,
danke fuer den Screenshot — drei Punkte, davon zwei derselbe Fehler. Alles gefixt, liegt auf dev. Keine neue Migration, 052 bleibt head, also nur bauen und neu starten (Backend + Frontend).
git pull && docker compose build && docker compose up -d
*1+2: die Assets, die "10" und "192" heissen — und die vermischte Zeile*
Nicht jedes Geraet antwortet Netdisco einen Namen (bei euch die AudioCodes-Gateway und der LANCOM). Der Sync faellt dann auf die kanonische IP zurueck, hat davon aber die Kurzform genommen — so wie er es bei einem FQDN macht (sw-12.example.local → sw-12). Auf einer Adresse angewandt bleibt das erste Oktett uebrig. Ab sofort ist ein namenloses Geraet die *volle IP*, eine Kurzform wird nur noch aus einem echten Namen gebildet.
Und das ist auch die Erklaerung fuer die Zeile mit LANCOM als Hersteller und der netgear-Firmware 5.4.2.30: der abgeschnittene Name war nicht nur haesslich, er war ein *Suchschluessel*. Das zweite namenlose Geraet im selben /8 hat ueber hostname="192" das Asset des ersten gefunden und wurde daraufgeschrieben. Zwei Geraete, eine Zeile. Mit der vollen Adresse kann das nicht mehr passieren.
Aufraeumen musst du nichts: heisst ein Asset genau wie das erste Oktett der IP, an die es gepinnt ist, benennt der naechste Sync es um, und das Geraet, das den Pin nicht haelt, bekommt sein eigenes Asset. Einzige Einschraenkung: die falsche os_version bleibt auf dem geheilten Asset stehen, bis dessen eigenes Geraet wieder eine Version meldet — ein leerer Wert ueberschreibt nie einen vorhandenen. Wenn dir das zu langsam ist, die zwei Zeilen einmal loeschen, der naechste Sync legt sie sauber neu an.
*3: Netdisco im Quellfilter*
War schlicht nicht in der Liste, und damit fehlte er auch im CSV-Export, der genau diesen Filter mitnimmt. Ist drin. Der Filter geht ueber die Netdisco-Verknuepfung und nicht ueber die Quelle, unter der ein Asset mal angelegt wurde — sonst faellt jeder Switch raus, den Wazuh oder Nessus zuerst gesehen hat. Nebenbei aufgefallen: dieselben Switche standen vorher unter "Manual", weil sie keine der abgefragten Scanner-Verknuepfungen hatten. Auch das stimmt jetzt.
*Was ich dich zu pruefen bitte*
1. Einmal syncen, dann die beiden Zeilen anschauen: voller Name statt "10"/"192", und jedes Geraet mit seinem eigenen Hersteller und seiner eigenen Firmware.
2. Inventar → Filter auf Netdisco: kommen alle Netzwerkgeraete, auch die, die ein anderer Scanner zuerst kannte? "Export CSV" bei gesetztem Filter → dieselbe Menge.
3. Unter "Manual" darf jetzt kein Switch mehr stehen.
4. Aruba/Cisco/Extreme unveraendert gegentesten, dass nichts kaputtgegangen ist.
5. Offline, ganz ohne Netdisco:
venv/bin/python tests/test_netdisco_nameless_device.py
venv/bin/python tests/test_netdisco_inventory.py
Die OS-Spalte zeigt bei den beiden weiterhin "AudioCodes LTD" bzw. "LANCOM Systems" — das ist der Hersteller, weil Netdisco fuer die Geraete gar keinen OS-Slug meldet. Kosmetisch, auf Findings hat es keinen Einfluss; sag Bescheid, wenn es stoeren soll.
+50
View File
@@ -0,0 +1,50 @@
"""Sync run ledger + SYNC_FAILURE notification type
Revision ID: 050
Revises: 049
Create Date: 2026-09-03 14:00:00.000000
A Wazuh API that refused every login for a day was visible only in the
container log. Every scan-jobs row is per asset, and a sync that fails at
authentication never reaches an asset — so /scans showed a clean history of
COMPLETED runs while nothing had been synced. This table records every sync
execution (any source, button or scheduler) with its outcome, and the new
notification type carries the failure mail in the notification log.
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
"""
from alembic import op
import sqlalchemy as sa
revision = "050"
down_revision = "049"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sync_runs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("source", sa.String(length=30), nullable=False),
sa.Column("trigger", sa.String(length=20), nullable=False, server_default="manual"),
sa.Column("status", sa.String(length=20), nullable=False, server_default="running"),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("stats", sa.Text(), nullable=True),
sa.Column("alerted_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_sync_runs_source", "sync_runs", ["source"])
op.create_index("ix_sync_runs_status", "sync_runs", ["status"])
with op.get_context().autocommit_block():
op.execute("ALTER TYPE notificationtype ADD VALUE IF NOT EXISTS 'SYNC_FAILURE'")
def downgrade() -> None:
op.drop_index("ix_sync_runs_status", table_name="sync_runs")
op.drop_index("ix_sync_runs_source", table_name="sync_runs")
op.drop_table("sync_runs")
# Postgres cannot drop an enum label — see 043; deliberately left in place.
@@ -0,0 +1,49 @@
"""Attach the M365 Apps security-updates page to existing M365 findings
Revision ID: 051
Revises: 050
Create Date: 2026-09-07 10:00:00.000000
M365 Apps fixes are not in NVD and Wazuh never sees them: the only public
record of "CVE-X is fixed in build Y" is the Microsoft Learn security-updates
page the M365 check parses. Findings now carry that page as an external
reference — the evidence they were raised from.
The check attaches it on its next run, but only to findings it re-raises: a
row already closed, or one on a host that no longer reports an M365 install,
would never get it. This fills those in once, with the plain page URL (the
monthly section a closed finding came from is not recoverable here — the
check deep-links the ones it re-raises).
Only rows the M365 check itself raised are touched, and only when they carry
no reference yet, so nothing another source wrote is overwritten.
"""
from alembic import op
revision = "051"
down_revision = "050"
branch_labels = None
depends_on = None
_URL = ("https://learn.microsoft.com/en-us/officeupdates/"
"microsoft365-apps-security-updates")
# "references" is a reserved word — it stays quoted in raw SQL.
_JSON = '[\"' + _URL + '\"]'
def upgrade() -> None:
op.execute(
'UPDATE vulnerabilities SET "references" = \'' + _JSON + '\' '
"WHERE first_detected_by = 'm365_check' "
'AND ("references" IS NULL OR "references" = \'\' '
'OR "references" = \'[]\')'
)
def downgrade() -> None:
op.execute(
'UPDATE vulnerabilities SET "references" = NULL '
"WHERE first_detected_by = 'm365_check' "
'AND "references" = \'' + _JSON + '\''
)
@@ -0,0 +1,48 @@
"""Add NETDISCO asset source + netdisco_device_ip
Revision ID: 052
Revises: 051
Create Date: 2026-09-08 09:00:00.000000
Netdisco as a sixth inventory source next to Wazuh, Nessus, Intune, vCenter
and IGEL. It registers every device Netdisco has discovered — the switch
estate, which carried no CVE detection at all until now: switch firmware runs
no Wazuh agent, no MDM enrols it, and a Nessus scan without SNMP credentials
sees an open port and no version.
`netdisco_device_ip` pins the asset to Netdisco's canonical device address —
the identifier Netdisco itself keys on, statically configured on a switch's
management interface and stable across the renames switches do get.
The firmware version needs no new column: it IS the OS version, so it lands in
`os_version` like every other OS. What lands there is the NUMERIC form
(16.11.0016); HPE's two-letter code-line prefix ("WC.") names the hardware
family, not the version, and is kept in the asset description where an
operator matching HPE's download page can still read it.
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres → autocommit_block. Idempotent (IF NOT EXISTS).
"""
from alembic import op
revision = "052"
down_revision = "051"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute("ALTER TYPE assetsource ADD VALUE IF NOT EXISTS 'NETDISCO'")
op.execute("""
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS netdisco_device_ip VARCHAR(45);
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_netdisco_device_ip "
"ON assets (netdisco_device_ip);")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_netdisco_device_ip;")
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS netdisco_device_ip;")
@@ -0,0 +1,43 @@
"""Add netdisco_serial — the Netdisco pin that survives a new address
Revision ID: 053
Revises: 052
Create Date: 2026-09-10 12:00:00.000000
Field report 2026-09-10: a UniFi AP updated its firmware, took a new DHCP
lease, and became a SECOND asset — same hostname, same serial, the old row
left INACTIVE by the reconcile because no device pointed at it any more.
`netdisco_device_ip` was the only Netdisco pin, and the hostname fallback is
pinned on it too, so the asset holding the OLD address was filtered out of the
lookup meant to catch exactly this. The serial number is the identity the
address only stands in for, and Netdisco already reports it per device — it
was just being written into the description text.
No backfill: the first sync after this migration matches every device on its
(unchanged, at that moment) address and stamps the serial then. Assets forked
before this are not merged — that is a manual decision, findings and audit
trail hang off both rows.
Idempotent (IF NOT EXISTS), same shape as 048.
"""
from alembic import op
revision = "053"
down_revision = "052"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS netdisco_serial VARCHAR(64);
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_netdisco_serial ON assets (netdisco_serial);")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_netdisco_serial;")
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS netdisco_serial;")
+46
View File
@@ -0,0 +1,46 @@
"""Add intune_serial — what tells a re-enrolment from a namesake
Revision ID: 054
Revises: 053
Create Date: 2026-09-11 12:00:00.000000
The Intune sync collapsed every managedDevice sharing a device name into one
record before touching an asset. That is right for the case it was written for
— wipe a device, enrol it again, and Intune keeps BOTH entries under the same
name — but it was keyed on the name alone. Two DIFFERENT devices with the same
name (a Windows default DESKTOP-XXXXXXX, a userless Android out of the same
batch) were collapsed too, and the one that lost dropped out of the sync
entirely: no asset, no inventory, no findings. A system missing from the
inventory is a coverage hole nobody sees.
The hardware serial separates the two cases and Graph already sends it in the
same payload (`serialNumber`, part of the $select). Re-enrolment keeps it; two
devices do not share it.
No backfill: the next sync stamps the serial on every device it matches by id
or name, exactly as 053 did for Netdisco. Assets already merged by name are
not split — findings and audit trail hang off the surviving row, so that stays
a manual decision.
Idempotent (IF NOT EXISTS), same shape as 053.
"""
from alembic import op
revision = "054"
down_revision = "053"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS intune_serial VARCHAR(64);
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_intune_serial ON assets (intune_serial);")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_intune_serial;")
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS intune_serial;")
+34
View File
@@ -0,0 +1,34 @@
"""Add igel_serial — the IGEL device's hardware serial
Revision ID: 055
Revises: 054
Create Date: 2026-09-14 12:00:00.000000
The IGEL sync pinned a thin client on `unitID` alone, and IMI's `unitID` is the
MAC (`mac` carries the same value). `deviceSerialNumber` came in the same
`?facets=details` payload and was never read, so no IGEL asset carried a
serial and a changed unit ID forked the asset.
No backfill: the next sync stamps the serial on every device it matches by
unit ID. Idempotent (IF NOT EXISTS), same shape as 053/054.
"""
from alembic import op
revision = "055"
down_revision = "054"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS igel_serial VARCHAR(64);
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assets_igel_serial ON assets (igel_serial);")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_assets_igel_serial;")
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS igel_serial;")
+28
View File
@@ -0,0 +1,28 @@
"""Add vmware_serial — the ESXi host's hardware serial
Revision ID: 056
Revises: 055
Create Date: 2026-09-14 15:00:00.000000
vCenter reports a host's chassis serial in `hardware.systemInfo` (the
`serialNumber` field, or the SMBIOS serial / service tag), and the sync never
read it. Display only: the hardware UUID stays the match key.
No backfill: the next vCenter sync stamps it. Idempotent (IF NOT EXISTS), same
shape as 053055.
"""
from alembic import op
revision = "056"
down_revision = "055"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS vmware_serial VARCHAR(64);")
def downgrade() -> None:
op.execute("ALTER TABLE assets DROP COLUMN IF EXISTS vmware_serial;")
@@ -0,0 +1,73 @@
"""Repair Defender's OS placeholder on application CVEs
Revision ID: 057
Revises: 056
Create Date: 2026-09-15 12:00:00.000000
Until d708a04 Defender labelled a CVE with the asset's OS and build whenever
its software export had not caught up yet, so Chrome and Edge CVEs read
"Microsoft Windows" / 10.0.26100.x (CVE-2026-87474). The sync now replaces
that on rows Defender still reports; this repairs the rest, meaning rows
Defender dropped, rows MSRC took over, and closed rows.
Only rows Defender created whose package is still the asset's OS string:
1. A package row another scanner recorded names the real product → take the
earliest one's name and version.
2. Otherwise, if the fix is a dotted version whose major differs from the
placeholder build (153.x against 10.0.x), the placeholder cannot be this
CVE's product. Clear name and version: unknown rather than wrong.
A Windows CVE fixed in 10.0.x or an iOS CVE at 18.x keeps its label.
Data only, not reversible. The downgrade is a no-op.
"""
import re
from alembic import op
from sqlalchemy import text
revision = "057"
down_revision = "056"
branch_labels = None
depends_on = None
_DOTTED = re.compile(r"^\d+(\.\d+)+$")
def _major(v):
return v.split(".")[0] if v and _DOTTED.match(v) else None
def repair(conn) -> int:
rows = conn.execute(text("""
SELECT v.id, v.package_version, v.fixed_version
FROM vulnerabilities v JOIN assets a ON a.id = v.asset_id
WHERE v.first_detected_by = 'defender'
AND a.operating_system IS NOT NULL AND a.operating_system <> ''
AND v.package_name = a.operating_system
""")).fetchall()
fixed = 0
for vid, pkg_ver, fix_ver in rows:
child = conn.execute(text("""
SELECT package_name, package_version FROM vulnerability_packages
WHERE vulnerability_id = :id ORDER BY first_detected_at, id LIMIT 1
"""), {"id": vid}).fetchone()
if child:
conn.execute(text("UPDATE vulnerabilities SET package_name = :n, "
"package_version = :ver WHERE id = :id"),
{"n": child[0], "ver": child[1], "id": vid})
elif _major(fix_ver) and _major(pkg_ver) and _major(fix_ver) != _major(pkg_ver):
conn.execute(text("UPDATE vulnerabilities SET package_name = NULL, "
"package_version = NULL WHERE id = :id"), {"id": vid})
else:
continue
fixed += 1
return fixed
def upgrade() -> None:
repair(op.get_bind())
def downgrade() -> None:
pass
@@ -0,0 +1,29 @@
"""Add vulnerabilities.vendor_severity — the vendors' own ratings
Revision ID: 058
Revises: 057
Create Date: 2026-09-15 10:00:00.000000
JSON {"Microsoft": "Critical", "TeamViewer": "Important", ...}. Vendors rate
by their own scale, which can differ from the CVSS band the severity column
follows: CVE-2026-85921 is CVSS 8.2 (High), Critical at MSRC. The detail view
shows a rating only where it disagrees.
No backfill: the next MSRC ingest (nightly 2 months, Sunday 18 months), app
scan and mobile check stamp it. Idempotent (IF NOT EXISTS), same shape as 053056.
"""
from alembic import op
revision = "058"
down_revision = "057"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS vendor_severity TEXT;")
def downgrade() -> None:
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS vendor_severity;")
@@ -0,0 +1,36 @@
"""Add CONTAINER asset source + vulnerabilities.baseline
Revision ID: 059
Revises: 058
Create Date: 2026-09-17 09:00:00.000000
Container images scanned by Trivy or Grype on a Docker host and pushed to
POST /api/v1/vulnerabilities/sync/container. One asset per image (repo:tag),
see container_scan_service. The image name is the identity, so no pin column.
`vulnerabilities.baseline` marks findings from a repository's first report,
which send no new-finding mail. Its own column, not notification_suppressed:
that is the operator's mute and would also silence SLA mails for good.
ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
Postgres → autocommit_block. Idempotent (IF NOT EXISTS). Postgres cannot drop
an enum value, so downgrade only drops the column.
"""
from alembic import op
revision = "059"
down_revision = "058"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute("ALTER TYPE assetsource ADD VALUE IF NOT EXISTS 'CONTAINER'")
op.execute("ALTER TABLE vulnerabilities "
"ADD COLUMN IF NOT EXISTS baseline BOOLEAN NOT NULL DEFAULT false;")
def downgrade() -> None:
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS baseline;")
@@ -0,0 +1,40 @@
"""Container image → Docker host asset
Revision ID: 060
Revises: 059
Create Date: 2026-09-18 12:00:00.000000
The Dockhand sync keeps one CONTAINER asset per image; which host runs it was
only text in the description. This table links an image to the host asset
(the Wazuh / Nessus / vCenter record of the Docker host) whose hostname the
Docker host reports, so the host shows its images and the image its host.
Rewritten by every Dockhand sync; both sides cascade on asset delete.
"""
from alembic import op
import sqlalchemy as sa
revision = "060"
down_revision = "059"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"container_placements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("image_asset_id", sa.Integer(),
sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("host_asset_id", sa.Integer(),
sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("containers", sa.Text(), nullable=True),
)
op.create_index("ix_container_placements_image_asset_id", "container_placements",
["image_asset_id"])
op.create_index("ix_container_placements_host_asset_id", "container_placements",
["host_asset_id"])
def downgrade() -> None:
op.drop_table("container_placements")
@@ -0,0 +1,28 @@
"""Add vulnerabilities.jev_config_dependent + jev_checked_at
Revision ID: 061
Revises: 060
Create Date: 2026-09-19 10:00:00.000000
Jev's answer to "only exploitable with a non-default configuration?", per CVE
text. An operator hint on the patch plan; it never closes a finding and never
enters priority_score. No backfill: the nightly Jev job fills it when enabled.
Idempotent (IF NOT EXISTS), same shape as 053058.
"""
from alembic import op
revision = "061"
down_revision = "060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS jev_config_dependent DOUBLE PRECISION;")
op.execute("ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS jev_checked_at TIMESTAMP;")
def downgrade() -> None:
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS jev_checked_at;")
op.execute("ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS jev_config_dependent;")
+2 -2
View File
@@ -17,7 +17,7 @@ from app.auth.token_revocation import is_revoked
security = HTTPBearer(auto_error=False)
async def get_current_user(
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
@@ -87,7 +87,7 @@ async def get_current_user(
return user
async def get_current_active_user(
def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
+7 -4
View File
@@ -63,6 +63,7 @@ class AuthOrchestrator:
*,
actor_ip: Optional[str] = None,
actor_user_agent: Optional[str] = None,
channel: Optional[str] = None,
) -> AuthResult:
"""
Try each credential-capable strategy in lookup_order. Returns
@@ -111,7 +112,7 @@ class AuthOrchestrator:
# IdP and never reach this code path.
mfa_required = bool(user.totp_enabled and user.totp_secret)
self._audit_login_success(user, prov, actor_ip, actor_user_agent)
self._audit_login_success(user, prov, actor_ip, actor_user_agent, channel)
return AuthResult(
user=user,
provider=prov,
@@ -122,7 +123,7 @@ class AuthOrchestrator:
)
# All strategies failed
self._audit_login_failure(username, provider_tried, last_error, actor_ip, actor_user_agent)
self._audit_login_failure(username, provider_tried, last_error, actor_ip, actor_user_agent, channel)
# Generic safe message (anti-enumeration)
raise AuthError(
detail=f"all providers failed for '{username}': {[p for p in provider_tried]}",
@@ -204,7 +205,7 @@ class AuthOrchestrator:
provisioner = JITProvisioner(self.db, default_role=self.default_role)
return provisioner.provision(identity, actor_ip=ip)
def _audit_login_success(self, user, provider: AuthProvider, ip, ua):
def _audit_login_success(self, user, provider: AuthProvider, ip, ua, channel=None):
evt = AuditEventType.LOGIN_SUCCESS
if provider == AuthProvider.LDAP:
evt = AuditEventType.LOGIN_LDAP_SUCCESS
@@ -215,7 +216,7 @@ class AuthOrchestrator:
AuditLog(
user_id=user.id,
event_type=evt,
event_description=f"login OK via {provider.value} as {user.username}",
event_description=f"login OK via {provider.value} as {user.username}" + (f" [{channel}]" if channel else ""),
ip_address=ip,
user_agent=ua,
resource_type="user",
@@ -232,6 +233,7 @@ class AuthOrchestrator:
error: Optional[AuthError],
ip,
ua,
channel=None,
):
self.db.add(
AuditLog(
@@ -241,6 +243,7 @@ class AuthOrchestrator:
f"login failed for '{username}' "
f"(tried: {', '.join(providers_tried) or 'none'}; "
f"reason: {error.detail if error else 'no providers configured'})"
+ (f" [{channel}]" if channel else "")
),
ip_address=ip,
user_agent=ua,
+3
View File
@@ -29,9 +29,12 @@ PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
"smtp_config",
"nessus_config",
"openrouter_api_key",
"typesafe_api_key",
"intune_config",
"vcenter_config",
"igel_config",
"netdisco_config",
"dockhand_config",
"github_pat",
})
+12 -2
View File
@@ -14,11 +14,21 @@ DATABASE_URL = os.getenv(
)
# Engine mit Security-Optimierungen
#
# pool_size + max_overflow must cover the request worker threadpool, not the
# expected load: the endpoints are sync `def`, so FastAPI runs them in that
# threadpool (40 threads by default) and each one holds a Session for its whole
# body. At 30 connections the 31st concurrent request did not wait for a slower
# query, it waited for a *connection* — and that is a timeout, not slowness.
# Raise both together with the thread limit or not at all.
POOL_SIZE = 20
MAX_OVERFLOW = 20 # 20 + 20 = the 40 threads FastAPI will hand out
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True, # Prüft Connection vor Nutzung
pool_size=10, # Connection Pool Größe
max_overflow=20, # Maximale zusätzliche Connections
pool_size=POOL_SIZE,
max_overflow=MAX_OVERFLOW,
echo=False, # SQL-Logging (in Production deaktivieren)
)
+111
View File
@@ -0,0 +1,111 @@
"""Dockhand client (Docker management UI with built-in Grype / Trivy scans).
Thin httpx wrapper, no DB writes — same contract as the other integration
clients. Read-only endpoints over Dockhand's cached scan results, plus one
that rescans:
* `GET /api/environments` — the Docker hosts Dockhand manages;
* `GET /api/host?env=<id>` — the Docker host's real hostname, for display;
* `GET /api/images?env=<id>` — only to tell a reachable host from one
Dockhand cannot reach (see dockhand_service);
* `GET /api/vulnerabilities/export?env=<id>&format=json` — that host's
findings, one row per (image, vulnerability id, package, version), for
images still present on the host (Dockhand ≥ 1.0.37, #415);
* `POST /api/vulnerabilities/scan-all?env=<id>` — rescans every image of
the host, sequentially. Dockhand has no scan schedule of its own (it scans
on pull / update / by hand), so the nightly sync calls this first.
Auth: `Authorization: Bearer dh_…`, an API token created in Dockhand under
the user's profile. The user needs `images:view` (and `environments:view` to
list the hosts; `images:inspect` for the rescan). An instance with auth
disabled needs no token.
"""
from __future__ import annotations
from typing import List
import httpx
# One image takes 1060 s, a Grype DB update on the first scan longer. Bounded
# so a hung Dockhand cannot hold the nightly job (and its lock) forever.
SCAN_ALL_READ_TIMEOUT = 3 * 3600.0
class DockhandError(RuntimeError):
"""Connection / authentication / API failure, with Dockhand's message kept."""
class DockhandClient:
def __init__(self, base_url: str, token: str = "", verify_ssl: bool = True):
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token.strip()}"
# Short connect, generous read: the export flattens every scan of a
# host in one response and is slow on a big one.
self._client = httpx.Client(
base_url=base_url.rstrip("/"), verify=verify_ssl, headers=headers,
timeout=httpx.Timeout(connect=8.0, read=120.0, write=30.0, pool=5.0),
)
def _request(self, method: str, path: str, timeout=None, **params) -> object:
try:
r = self._client.request(method, path, params=params,
timeout=timeout or self._client.timeout)
except httpx.HTTPError as e:
raise DockhandError(f"Dockhand unreachable: {e}") from e
if r.status_code in (401, 403):
raise DockhandError(f"Dockhand refused the token (HTTP {r.status_code}) — it needs "
"images:view and environments:view (images:inspect to rescan).")
if r.status_code >= 400:
raise DockhandError(f"Dockhand {path} failed (HTTP {r.status_code}): {r.text[:300]}")
try:
return r.json()
except ValueError as e:
# A reverse proxy's login page answers 200 with HTML.
raise DockhandError(f"Dockhand {path} returned no JSON — wrong URL?") from e
def _get(self, path: str, **params) -> object:
return self._request("GET", path, **params)
def environments(self) -> List[dict]:
got = self._get("/api/environments")
if not isinstance(got, list):
raise DockhandError("Dockhand /api/environments returned no list")
return got
def images(self, env_id: int) -> List[dict]:
"""The host's images. Dockhand answers `[]` — not an error — when it
cannot reach the Docker host."""
got = self._get("/api/images", env=env_id)
if not isinstance(got, list):
raise DockhandError(f"Dockhand /api/images for environment {env_id} returned no list")
return got
def host(self, env_id: int) -> dict:
"""The Docker daemon's host: `hostname` is `docker info` Name, the real
host's name even behind a socket or a Hawser agent."""
got = self._get("/api/host", env=env_id)
return got if isinstance(got, dict) else {}
def findings(self, env_id: int) -> List[dict]:
got = self._get("/api/vulnerabilities/export", env=env_id, format="json")
if not isinstance(got, dict) or not isinstance(got.get("findings"), list):
raise DockhandError(f"Dockhand export for environment {env_id} has no findings list "
"— Dockhand older than 1.0.37?")
return got["findings"]
def scan_all(self, env_id: int) -> dict:
"""Rescan every image of the host and wait for it. `Accept: application/json`
makes Dockhand answer synchronously with the summary
`{scanned, failed, total, cancelled}` (or `{success: false, error}`)
instead of a job id."""
got = self._request("POST", "/api/vulnerabilities/scan-all", env=env_id,
timeout=httpx.Timeout(connect=8.0, read=SCAN_ALL_READ_TIMEOUT,
write=30.0, pool=5.0))
if not isinstance(got, dict):
raise DockhandError(f"Dockhand scan-all for environment {env_id} returned no object")
return got
def close(self) -> None:
self._client.close()
+3
View File
@@ -230,6 +230,9 @@ class IgelClient:
# inventory travelling through a vulnerability scanner for no purpose.
return {
"unit_id": (r.get("unitID") or "").strip(),
# The manufacturer serial. `unitID` and `mac` are both the MAC,
# `id` is the UMS row and `assetID` a mostly empty free-text field.
"serial": str(r.get("deviceSerialNumber") or "").strip(),
"firmware_id": str(r.get("firmwareID") or ""),
"name": (r.get("name") or "").strip(),
"network_name": (r.get("networkName") or "").strip(),
+202
View File
@@ -0,0 +1,202 @@
"""Netdisco client (Netdisco 2 REST API, /api/v1).
Thin httpx wrapper, no DB writes same contract as the other integration
clients. One endpoint is read and nothing else:
* `GET /api/v1/report/device/deviceinventory` every device Netdisco has
discovered, one row each: name, canonical IP, location, model, serial,
vendor, OS and OS version. That last pair is the whole point: a switch
exposes its firmware to SNMP and to nothing else here, so this report is
the only place an ArubaOS / AOS-CX version can come from.
Auth, and why BOTH forms are supported:
* an API key sent as `Authorization: <key>` (Netdisco's own header spelling —
no "Bearer", no "Token"), which is what a production instance should use:
it is per-user, revocable, and never puts a password on the wire;
* a username + password, which are POSTed once to `/login` and exchanged for
exactly such a key. That is Netdisco's documented way to MINT one, so the
two are the same mechanism with a different starting point rather than two
code paths.
* neither, for an instance with no authentication at all the public demo
answers the report unauthenticated, and refusing to talk to it would make
the connector untestable before it is pointed at production.
Scheme: http AND https, chosen by the operator. Netdisco's own web server
(the Docker image included) speaks plain HTTP on port 5000 and cannot be
configured for TLS at all; a production deployment fronts it with a reverse
proxy that terminates HTTPS on some other port. Hard-coding either one would
lock out half of the installations, so both are configured, and `verify_ssl`
is separate because such a proxy often carries an internal CA's certificate.
API reference: https://<your-netdisco>/swagger-ui/
"""
from __future__ import annotations
import logging
from typing import List, Optional
import httpx
logger = logging.getLogger(__name__)
DEFAULT_PORT = 5000
INVENTORY_PATH = "/api/v1/report/device/deviceinventory"
class NetdiscoError(RuntimeError):
"""Connection / authentication failure, with Netdisco's message kept."""
class NetdiscoClient:
def __init__(self, host: str, api_key: str = "", username: str = "",
password: str = "", port: int = DEFAULT_PORT,
use_https: bool = False, verify_ssl: bool = True):
self.host = _clean_host(host)
self.api_key = (api_key or "").strip()
self.username = (username or "").strip()
self.password = password or ""
self.port = int(port or DEFAULT_PORT)
self.use_https = bool(use_https)
scheme = "https" if use_https else "http"
self.base_url = f"{scheme}://{self.host}:{self.port}"
# Short connect timeout, generous read: the same reasoning as the
# Nessus and IGEL clients — an unreachable Netdisco must fail fast
# instead of holding a worker, while the inventory report over a
# campus-sized estate is genuinely slow to serialise.
self._client = httpx.Client(
base_url=self.base_url,
verify=verify_ssl,
timeout=httpx.Timeout(connect=8.0, read=120.0, write=30.0, pool=5.0),
headers={"Accept": "application/json"},
)
self._logged_in = False
# ---------- connection ----------
def login(self) -> None:
"""Exchange username+password for an API key, once.
Skipped entirely when a key was configured (it already IS the result of
this call) or when no credentials were given at all an instance
without authentication answers the report regardless, and demanding a
login from it would fail on the one deployment that needs none.
"""
if self._logged_in or self.api_key or not self.username:
return
try:
r = self._client.post("/login", auth=(self.username, self.password))
except httpx.HTTPError as e:
raise NetdiscoError(f"Netdisco login failed for {self.host}: {e}") from e
if r.status_code in (401, 403):
raise NetdiscoError("Netdisco rejected the credentials "
f"(HTTP {r.status_code}). Check the user, and that "
"it is allowed to use the API.")
if r.status_code >= 400:
raise NetdiscoError(f"Netdisco login failed (HTTP {r.status_code}): {_err(r)}")
try:
key = (r.json() or {}).get("api_key")
except ValueError:
key = None
if not key:
raise NetdiscoError("Netdisco login returned no api_key — the user "
"exists but has no API access.")
self.api_key = str(key).strip()
self._logged_in = True
def close(self) -> None:
self._client.close()
def _get(self, path: str) -> object:
self.login()
# Netdisco's own spelling: the raw key in `Authorization`, no scheme
# word in front of it. Sent only when there is one — an unauthenticated
# instance answers a header-less request and 400s on a bogus one.
headers = {"Authorization": self.api_key} if self.api_key else None
try:
r = self._client.get(path, headers=headers)
except httpx.HTTPError as e:
raise NetdiscoError(f"Netdisco GET {path} failed: {e}") from e
if r.status_code in (401, 403):
raise NetdiscoError(
f"Netdisco rejected the request (HTTP {r.status_code}) — the API "
"key is wrong, expired, or the instance requires a login.")
if r.status_code >= 400:
raise NetdiscoError(f"Netdisco GET {path} failed "
f"(HTTP {r.status_code}): {_err(r)}")
try:
return r.json()
except ValueError as e:
# An HTML body here is the classic misconfiguration: the login page,
# served with HTTP 200, because the request was not authenticated.
raise NetdiscoError(
f"Netdisco GET {path} returned no JSON (a login page, most "
"likely — check the API key)") from e
# ---------- reads ----------
def get_devices(self) -> List[dict]:
"""Every discovered device, one call.
Rows with no IP are dropped here rather than by the caller: Netdisco
keys a device on its canonical address, so a row without one cannot be
pinned to an asset and would fork a new one on every sync.
"""
data = self._get(INVENTORY_PATH)
rows = data if isinstance(data, list) else (data or {}).get("results", [])
out = []
for r in rows:
if isinstance(r, dict) and (r.get("ip") or "").strip():
out.append(self._device_dict(r))
return out
def test_connection(self) -> dict:
"""Credential + reachability probe for the settings UI."""
try:
self.login()
except NetdiscoError as e:
return {"ok": False, "step": "login", "error": str(e)}
try:
devices = self.get_devices()
except NetdiscoError as e:
return {"ok": False, "step": "inventory", "error": str(e)}
from app.services.netdisco_service import firmware_family
# Devices on a firmware line we can actually decide — the number that
# says whether pointing the connector here will produce findings, not
# just assets.
matched = sum(1 for d in devices if firmware_family(d))
return {"ok": True, "device_count": len(devices), "fw_count": matched,
"auth": "api-key" if self.api_key else "none",
"url": self.base_url + INVENTORY_PATH}
@staticmethod
def _device_dict(r: dict) -> dict:
# Netdisco's column names, mapped once here so nothing downstream has to
# know them. `version` is the OS VERSION column of the report (the
# firmware string, "WC.16.11.0016"), not a Netdisco version.
return {
"ip": (r.get("ip") or "").strip(),
"name": (r.get("device_name") or "").strip(),
"details": (r.get("device_details") or "").strip(),
"location": (r.get("location") or "").strip().strip('"'),
"model": (r.get("model") or "").strip(),
"serial": (r.get("serial") or "").strip(),
"vendor": (r.get("vendor") or "").strip(),
"os": (r.get("os") or "").strip(),
"os_version": (r.get("version") or "").strip(),
}
def _clean_host(host: str) -> str:
return ((host or "").strip()
.replace("https://", "").replace("http://", "")
.rstrip("/").split("/")[0])
def _err(r: httpx.Response) -> str:
"""Netdisco's error message, which lives in the body, not the status line."""
try:
body = r.json()
if isinstance(body, dict):
return str(body.get("error") or body.get("message") or body)[:300]
except Exception:
pass
return (r.text or "")[:300]
+37 -7
View File
@@ -7,7 +7,7 @@ clients. Two things are read and nothing else:
and instanceUuid;
* every ESXi host in the inventory one PropertyCollector pass over
`HostSystem` for name, product version + build, management IP, hardware
model and connection state.
model, UUID and serial, and connection state.
Why pyVmomi and not the REST API: `GET /api/vcenter/host` returns a host's
name, power and connection state and no version or build at all. The build
@@ -38,9 +38,10 @@ _HOST_PROPS = [
"config.product.build",
"config.product.fullName",
"config.network.vnic",
"hardware.systemInfo.uuid",
"hardware.systemInfo.vendor",
"hardware.systemInfo.model",
# The whole HostSystemInfo, not its leaves: `serialNumber` only exists from
# API 6.7 on, and a property path the server does not know faults the
# entire RetrieveContents — every host, not just the one field.
"hardware.systemInfo",
"runtime.connectionState",
]
@@ -152,19 +153,48 @@ class VCenterClient:
@staticmethod
def _host_dict(obj) -> dict:
props = {p.name: p.val for p in (obj.propSet or [])}
si = props.get("hardware.systemInfo")
return {
"name": props.get("name"),
"version": props.get("config.product.version"),
"build": props.get("config.product.build"),
"full_name": props.get("config.product.fullName"),
"ip_address": _vmk_ip(props.get("config.network.vnic")),
"uuid": props.get("hardware.systemInfo.uuid"),
"vendor": props.get("hardware.systemInfo.vendor"),
"model": props.get("hardware.systemInfo.model"),
"uuid": getattr(si, "uuid", None),
"vendor": getattr(si, "vendor", None),
"model": getattr(si, "model", None),
"serial": _host_serial(si),
"connection_state": str(props.get("runtime.connectionState") or ""),
}
# Where a server's chassis serial hides when `serialNumber` is empty (API < 6.7,
# or a BIOS that only fills the SMBIOS tags): Dell reports its service tag as
# ServiceTag, HPE and Lenovo as SerialNumberTag / EnclosureSerialNumberTag.
# AssetTag is left out on purpose — it is free text an operator types into
# the BIOS, not the manufacturer's number.
_SERIAL_TAGS = ("SerialNumberTag", "EnclosureSerialNumberTag", "ServiceTag")
def _host_serial(si) -> Optional[str]:
"""The ESXi host's hardware serial, or None when the box reports none."""
from app.services.asset_matching import stable_serial
if si is None:
return None
serial = stable_serial(getattr(si, "serialNumber", None))
if serial:
return serial
tags = {}
for info in getattr(si, "otherIdentifyingInfo", None) or []:
key = getattr(getattr(info, "identifierType", None), "key", None)
tags.setdefault(key, getattr(info, "identifierValue", None))
for key in _SERIAL_TAGS:
serial = stable_serial(tags.get(key))
if serial:
return serial
return None
def _vmk_ip(vnics) -> Optional[str]:
"""Management IP from the host's VMkernel adapters.
+29 -5
View File
@@ -142,6 +142,12 @@ class WazuhClient:
logger.error(f"Wazuh Auth error: {e}")
raise WazuhAuthenticationError(f"Authentication failed: {e}")
def login(self) -> None:
"""Authenticate now (or reuse a live token). The jobs that read the
manager API per asset call this once up front, so a refused login is
one recorded failure instead of a warning per asset."""
self._ensure_authenticated()
def _ensure_authenticated(self) -> str:
"""
Stellt sicher, dass ein gültiger Token vorhanden ist
@@ -576,9 +582,16 @@ class WazuhClient:
logger.info(f"Agent {agent_id}: Returning {len(results)} vulnerabilities from indexer")
return results
except WazuhAPIError:
raise
except Exception as e:
# An agent whose CVE list could not be read is NOT an agent with
# no CVEs. This used to log and return [] — with a wrong indexer
# password every agent came back "clean", every caller counted it
# as synced, and the sync run finished COMPLETED (2026-09-06).
# Ingest fails loud: every caller already catches per agent.
logger.error(f"Fehler beim Abfragen des Indexers fuer Agent {agent_id}: {e}")
return []
raise WazuhAPIError(f"Indexer query failed for agent {agent_id}: {e}")
def get_vulnerability_summary(self, agent_id: str) -> Dict[str, int]:
"""
@@ -708,10 +721,21 @@ class WazuhClient:
resp = self._indexer_request(
"POST", f"{self._EXT_INDEX}/_search", json_data=body)
except WazuhAPIError as e:
# IT Hygiene is optional and only exists from 4.14 — a missing
# index must not take the whole scan down with it.
logger.debug("browser extensions unavailable for %s: %s", agent_id, e)
return []
# IT Hygiene is optional and only exists from 4.14 — a MISSING
# index must not take the whole scan down with it, so that one
# answers "no extensions".
#
# Anything else must not. An indexer that is down or refusing auth
# also came back here, and returning [] told the scanner this host
# runs no extensions at all — whereupon its reconcile closed every
# extension finding on it as "no longer detected". Unreachable is
# not empty; say so and let the caller decide.
msg = str(e)
if "404" in msg or "index_not_found" in msg:
logger.debug("browser extensions unavailable for %s: %s", agent_id, e)
return []
logger.warning("browser extension fetch failed for %s: %s", agent_id, e)
raise
out: List[Dict[str, Any]] = []
for hit in (resp.get("hits", {}) or {}).get("hits", []) or []:
src = hit.get("_source") or {}
+22 -5
View File
@@ -29,7 +29,7 @@ from slowapi.errors import RateLimitExceeded
from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance, intune, vcenter, igel, advisories
from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance, intune, vcenter, igel, netdisco, dockhand, advisories
try:
from app.routers import auth_oidc
_HAS_OIDC = True
@@ -191,16 +191,31 @@ def _safe_query_string(query: str) -> str:
return "?" + "&".join(parts)
# Anything slower than this is worth finding in a log by eye. The dashboard
# fires thirteen requests at once, so "the page is slow" is only actionable
# once a single line says WHICH of them was slow.
SLOW_REQUEST_MS = 1000
@app.middleware("http")
async def log_requests(request: Request, call_next):
safe_target = f"{request.url.path}{_safe_query_string(request.url.query)}"
logger.info(f"➡️ Incoming Request: {request.method} {safe_target}")
started = time.perf_counter()
try:
response = await call_next(request)
logger.info(f"⬅️ Response: {response.status_code}")
# The path and the duration belong on the RESPONSE line, not just the
# request one. Concurrent requests interleave — the dashboard alone
# issues thirteen — so a bare "⬅️ Response: 200" cannot be tied back to
# the request it answers, and a report of "the page loads slowly" had
# no line to point at.
ms = (time.perf_counter() - started) * 1000
line = f"⬅️ Response: {response.status_code} {request.method} {safe_target} in {ms:.0f}ms"
logger.warning(f"🐢 SLOW {line}") if ms >= SLOW_REQUEST_MS else logger.info(line)
return response
except Exception as e:
logger.error(f"❌ Request Failed: {e}")
ms = (time.perf_counter() - started) * 1000
logger.error(f"❌ Request Failed after {ms:.0f}ms: {request.method} {safe_target}: {e}")
raise
@app.middleware("http")
@@ -293,7 +308,7 @@ async def global_exception_handler(request: Request, exc: Exception):
# ============================================
@app.get("/health", tags=["Health"])
async def health_check():
def health_check():
"""
Health-Check-Endpoint für Load-Balancer & Monitoring.
@@ -306,7 +321,7 @@ async def health_check():
@app.get("/", tags=["Root"])
@limiter.limit("10/minute")
async def root(request: Request):
def root(request: Request):
"""
Root-Endpoint mit API-Info
"""
@@ -333,6 +348,8 @@ app.include_router(nessus.router)
app.include_router(intune.router)
app.include_router(vcenter.router)
app.include_router(igel.router)
app.include_router(netdisco.router)
app.include_router(dockhand.router)
app.include_router(advisories.router)
app.include_router(assets.router)
app.include_router(policies.router)
+2
View File
@@ -13,6 +13,7 @@ from app.models.policy import Policy
from app.models.scan_schedule import ScanSchedule
from app.models.notification_log import NotificationLog
from app.models.setting import Setting
from app.models.sync_run import SyncRun
from app.models.ai_report import AIReport
from app.models.cve_remediation import CveRemediation
from app.models.app_cve_cache import AppCveCache
@@ -33,6 +34,7 @@ __all__ = [
"ScanSchedule",
"NotificationLog",
"Setting",
"SyncRun",
"AIReport",
"CveRemediation",
"AppCveCache",
+44 -1
View File
@@ -23,6 +23,8 @@ class AssetSource(str, Enum):
INTUNE = "INTUNE"
VCENTER = "VCENTER"
IGEL = "IGEL"
NETDISCO = "NETDISCO"
CONTAINER = "CONTAINER" # one asset per image, Trivy/Grype push (059)
class AssetStatus(str, Enum):
@@ -58,6 +60,12 @@ class Asset(Base, TimestampMixin):
# Graph syncs reconnect deterministically. defender_machine_id maps the
# asset to its Microsoft Defender for Endpoint machine (TVM, phase 3).
intune_device_id = Column(String(64), nullable=True, index=True)
# Hardware serial as Intune reports it. The device id is NOT stable — a
# wipe-and-re-enrol issues a new one for the same hardware — so the serial
# is what tells a re-enrolment (same serial, keep the asset and its
# history) from two devices that merely share a name (different serials,
# two assets). Same role netdisco_serial plays for a switch.
intune_serial = Column(String(64), nullable=True, index=True)
defender_machine_id = Column(String(64), nullable=True, index=True)
# Entra/AAD device id — the stable anchor shared by Intune (azureADDeviceId)
# and Defender (aadDeviceId); merges the same physical device across both.
@@ -71,11 +79,31 @@ class Asset(Base, TimestampMixin):
# vmware_release_service.
vmware_uuid = Column(String(64), nullable=True, index=True)
vmware_build = Column(String(32), nullable=True)
# The ESXi host's hardware serial (service tag), as vCenter reports it.
# Shown to the operator only — the UUID stays the match key (056).
vmware_serial = Column(String(64), nullable=True)
# IGEL UMS unit ID of an endpoint device (MAC-derived, and the identifier
# UMS itself keys on — it survives renames, re-registration and a UMS
# restore), or the UMS server's own `serverUUID`. The pin that reconnects
# the asset on every sync.
igel_unit_id = Column(String(64), nullable=True, index=True)
# IMI `deviceSerialNumber` — the manufacturer serial. `unitID` and `mac`
# are the same value (the MAC), so a board/NIC swap or a UD Pocket
# re-registration changes the unit ID while the hardware stays; the serial
# is what finds the asset again.
igel_serial = Column(String(64), nullable=True, index=True)
# Netdisco's canonical device IP — the address Netdisco itself keys a
# device on, statically configured on a switch's management interface and
# stable across the renames switches do get. The pin that reconnects the
# asset on every sync.
netdisco_device_ip = Column(String(45), nullable=True, index=True)
# …and the device's serial number, which is the identity the address only
# stands in for: a switch keeps its management address, an access point on
# a DHCP lease does not, and a re-addressed device used to fork a second
# asset while the first went INACTIVE. Pinned once and then left alone —
# stack members share a name and an address but bring a serial each, and
# the asset keeps the first one it saw.
netdisco_serial = Column(String(64), nullable=True, index=True)
# System-Information
operating_system = Column(String(255), nullable=True)
@@ -121,7 +149,7 @@ class Asset(Base, TimestampMixin):
last_scan = Column(DateTime, nullable=True)
last_seen = Column(DateTime, nullable=True)
# Which sync wrote the stamps above ("wazuh" | "nessus" | "intune" |
# "defender" | "vcenter" | "igel" | "manual"). Without it the nightly
# "defender" | "vcenter" | "igel" | "netdisco" | "manual"). Without it the
# time-based reconcile could only say "seen again by a source sync" — an
# audit line an operator cannot act on, because an iPhone that Intune has
# deleted and Defender TVM still reports looks identical to one Intune
@@ -158,3 +186,18 @@ class Asset(Base, TimestampMixin):
def __repr__(self):
return f"<Asset(hostname='{self.hostname}', ip='{self.ip_address}')>"
class ContainerPlacement(Base):
"""A container image (CONTAINER asset) runs on this host asset, as these
containers. Written by the Dockhand sync only, and only where the Docker
host's own hostname names exactly one host asset (060). Rewritten on every
sync, so an image gone from the host drops its row with the next run."""
__tablename__ = "container_placements"
id = Column(Integer, primary_key=True)
image_asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False, index=True)
host_asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False, index=True)
containers = Column(Text, nullable=True) # "nginx, web-1" — display only
+2
View File
@@ -17,6 +17,8 @@ class NotificationType(str, Enum):
# Actively exploited (CISA KEV / ENISA EUVD) AND present in our inventory —
# its own type so the log can be filtered for the alerts that meant "now".
KEV_ALERT = "kev_alert"
# A sync job (Wazuh/Nessus/Intune/vCenter/IGEL) failed — coverage gap.
SYNC_FAILURE = "sync_failure"
MANUAL = "manual"
+31
View File
@@ -0,0 +1,31 @@
"""SyncRun — one row per sync job execution, whatever the source or trigger.
The `scans` table records per-asset results, so a sync that dies BEFORE it
reaches any asset (Wazuh authentication refused, Nessus unreachable, IGEL UMS
down) left no trace anywhere but the container log. This ledger is the
source-level record: started, finished, status, and the error that stopped it.
"""
from sqlalchemy import Column, Integer, String, DateTime, Text
from app.models.base import Base
class SyncRun(Base):
__tablename__ = "sync_runs"
id = Column(Integer, primary_key=True, index=True)
# wazuh | nessus | intune | vcenter | igel
source = Column(String(30), nullable=False, index=True)
# manual | scheduled
trigger = Column(String(20), nullable=False, default="manual")
# running | completed | failed (plain strings: no Postgres enum to migrate)
status = Column(String(20), nullable=False, default="running", index=True)
started_at = Column(DateTime, nullable=False)
finished_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
# JSON: the service's stats dict (agents synced, created, errors[] ...)
stats = Column(Text, nullable=True)
# When a failure mail went out for this run — the cooldown anchor.
alerted_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<SyncRun(source='{self.source}', status='{self.status}')>"
+77
View File
@@ -37,6 +37,9 @@ class VulnerabilityStatus(str, Enum):
# and a genuinely critical finding sorted below a medium with a score. Taking
# the band's FLOOR keeps the estimate conservative: it can only ever
# understate a real score, never inflate one.
# Every vendor scale seen: FIRST's and Microsoft's (TeamViewer uses it too).
_VENDOR_RATINGS = {"critical", "important", "high", "moderate", "medium", "low"}
_SEVERITY_FLOOR = {"critical": 9.0, "high": 7.0, "medium": 4.0, "low": 0.1}
@@ -88,6 +91,12 @@ class Vulnerability(Base, TimestampMixin):
# CVSS-Bewertung
cvss_score = Column(Float, nullable=True, index=True)
cvss_vector = Column(String(100), nullable=True)
# The vendors' own ratings, JSON {"Microsoft": "Critical", ...}, written
# through set_vendor_severity. `severity` follows the CVSS, and vendors rate
# by their own scale — CVE-2026-85921 is CVSS 8.2 (High) but Critical at
# MSRC. Its own column, not appended to `description` like Mozilla's:
# M365 and Nessus rewrite that text every night.
vendor_severity = Column(Text, nullable=True)
severity = Column(
SQLEnum(VulnerabilitySeverity),
nullable=False,
@@ -145,6 +154,10 @@ class Vulnerability(Base, TimestampMixin):
# Notification Control
notification_suppressed = Column(Boolean, default=False, nullable=False)
# Imported with a container image repository's first report: already
# there before anyone looked, so no new-finding mail (059). Not a mute —
# SLA and KEV alerts still apply.
baseline = Column(Boolean, default=False, nullable=False, server_default="false")
# Enrichment: EPSS (Exploit Prediction Scoring System - FIRST.org)
epss_score = Column(Float, nullable=True, index=True) # 0.0 - 1.0 probability of exploitation in next 30 days
@@ -203,6 +216,13 @@ class Vulnerability(Base, TimestampMixin):
metasploit_module_count = Column(Integer, nullable=False, default=0, server_default="0")
exploit_intel_updated_at = Column(DateTime, nullable=True)
# Jev (TypeSafe, direct or via OpenRouter Decisions) — probability that the CVE is
# only exploitable with an optional feature / non-default configuration.
# A HINT for the operator to check, per CVE text: it never closes a
# finding and never enters priority_score. See jev_triage_service.
jev_config_dependent = Column(Float, nullable=True)
jev_checked_at = Column(DateTime, nullable=True)
# Relationships
asset = relationship("Asset", back_populates="vulnerabilities")
assigned_user = relationship("User", foreign_keys=[assigned_user_id])
@@ -266,6 +286,39 @@ class Vulnerability(Base, TimestampMixin):
self.sources = json.dumps(current)
return True
@property
def reference_list(self) -> list:
"""Parsed `references` JSON column (URLs). Empty on malformed JSON."""
import json
if not self.references:
return []
try:
value = json.loads(self.references)
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(value, list):
return []
return [u for u in value if isinstance(u, str)]
def add_reference(self, url: str) -> bool:
"""Append `url` to `references` if not already present. Returns True
if the list changed. Caller is responsible for committing.
The column is shared: Nessus writes its see_also here, vendor-bulletin
scanners their advisory, the M365 check its release-notes section. So
a source adds its own evidence and never drops another's — the
cross-source contract holds for evidence, not just for `sources`.
"""
import json
if not url:
return False
current = self.reference_list
if url in current:
return False
current.append(url)
self.references = json.dumps(current)
return True
def remove_source(self, scanner: str) -> bool:
"""Drop `scanner` from `sources`. Returns True if list changed.
Caller is responsible for committing."""
@@ -277,6 +330,30 @@ class Vulnerability(Base, TimestampMixin):
self.sources = json.dumps(current)
return True
@property
def vendor_severities(self) -> dict:
import json
try:
got = json.loads(self.vendor_severity or "{}")
except (TypeError, ValueError):
return {}
return got if isinstance(got, dict) else {}
def set_vendor_severity(self, vendor: str, rating) -> bool:
"""Record `vendor`'s rating, title-cased. Words off the scale
("Informative") or none at all are not a rating and never remove
one: a bulletin that says nothing is not a retraction."""
import json
word = str(rating or "").strip().title()
if not vendor or word.lower() not in _VENDOR_RATINGS:
return False
cur = self.vendor_severities
if cur.get(vendor) == word:
return False
cur[vendor] = word
self.vendor_severity = json.dumps(cur, sort_keys=True)
return True
def sync_severity_with_cvss(self) -> bool:
"""Keep `severity` consistent with `cvss_score`.
+14 -2
View File
@@ -60,16 +60,28 @@ def kev_recent(
"""Most recently listed actively-exploited CVEs across every KEV source
(CISA KEV + ENISA EUVD + VulnCheck KEV), newest first, annotated with
inventory impact (open vs already-patched assets, active assets only)."""
from app.services.kev_service import KEV_SOURCES, get_recent_kev
from app.services.kev_service import KEV_SOURCES, get_recent_kev, source_status
picked = [s.strip() for s in sources.split(",")] if sources else None
status = source_status(db)
return {
"items": get_recent_kev(db, limit=limit, sources=picked,
in_inventory_only=in_inventory_only),
"sources": [{"id": k, "label": v.label, "url": v.url}
"sources": [{"id": k, "label": v.label, "url": v.url, **status[k]}
for k, v in KEV_SOURCES.items()],
}
@router.post("/kev/refresh")
# Sync def → worker threadpool; three catalog downloads are blocking I/O.
def refresh_kev(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Re-fetch every KEV catalog (CISA, ENISA EUVD, VulnCheck) now."""
from app.services.kev_service import refresh_kev_sources
return refresh_kev_sources(db)
@router.get("/kev-alerts")
def kev_alert_preview(
db: Session = Depends(get_db),
+192 -53
View File
@@ -13,7 +13,7 @@ from pydantic import BaseModel, field_validator
from app.database import get_db
from app.models.user import User
from app.models.asset import Asset, AssetSource, AssetStatus
from app.models.asset import Asset, AssetSource, AssetStatus, ContainerPlacement
from app.models.policy import Policy
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
from app.models.audit_log import AuditLog, AuditEventType
@@ -46,6 +46,16 @@ class AssetResponse(BaseModel):
# name. Read-only in the UI; it is what an operator needs to exclude the
# right machine in Defender (field report 2026-08-30).
aad_device_id: Optional[str] = None
# The remaining per-source identity pins, read-only in the edit dialog so
# an operator can see what a sync reconnects the asset by (and the serial
# to find the hardware with).
vmware_uuid: Optional[str] = None
vmware_serial: Optional[str] = None
igel_unit_id: Optional[str] = None
igel_serial: Optional[str] = None
netdisco_device_ip: Optional[str] = None
netdisco_serial: Optional[str] = None
intune_serial: Optional[str] = None
operating_system: Optional[str]
os_version: Optional[str]
# VMware VMkernel/vpxd build number — the precise fix identifier for
@@ -76,6 +86,10 @@ class AssetResponse(BaseModel):
# Risk Dimensions (crown-jewel roles)
high_value_score: Optional[float] = None
risk_dimensions: Optional[List[dict]] = None
# Docker host ↔ image links from the Dockhand sync (container_placements):
# a host's number of images, an image's hosts as {id, hostname}.
container_image_count: int = 0
runs_on: List[dict] = []
@field_validator('exposed_services', 'risk_dimensions', mode='before')
@classmethod
@@ -149,7 +163,7 @@ class BulkAssetUpdateRequest(BaseModel):
# ============================================
@router.post("/bulk-update")
async def bulk_update_assets(
def bulk_update_assets(
update_data: BulkAssetUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -206,7 +220,7 @@ async def bulk_update_assets(
@router.post("/reconcile-lifecycle")
async def reconcile_lifecycle(
def reconcile_lifecycle(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
@@ -396,6 +410,33 @@ def asset_software(
"count": len(out), "software": out}
@router.get("/{asset_id}/containers")
def asset_containers(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""The container images Dockhand reports on this Docker host, with their
containers and open findings. Only images whose Docker host's hostname
names this asset (container_placements); clean images are not in
Dockhand's export and so not here either."""
host = db.query(Asset).filter(Asset.id == asset_id).first()
if not host:
raise HTTPException(status_code=404, detail="Asset not found")
open_count = (db.query(func.count(Vulnerability.id))
.filter(Vulnerability.asset_id == Asset.id,
Vulnerability.status == VulnerabilityStatus.open)
.correlate(Asset).scalar_subquery())
rows = (db.query(Asset, ContainerPlacement.containers, open_count)
.join(ContainerPlacement, ContainerPlacement.image_asset_id == Asset.id)
.filter(ContainerPlacement.host_asset_id == asset_id)
.order_by(func.lower(Asset.hostname)).all())
return {"hostname": host.hostname, "images": [
{"id": a.id, "image": a.hostname, "status": a.status, "containers": containers,
"vulnerability_count": n or 0, "last_scan": a.last_scan}
for a, containers, n in rows]}
@router.post("/refresh-exposure")
def refresh_exposure(
db: Session = Depends(get_db),
@@ -433,7 +474,8 @@ def refresh_exposure(
return refresh_all_exposure(db, wazuh)
def _apply_asset_filters(query, db, status=None, source=None, search=None, include_inactive=False):
def _apply_asset_filters(query, db, status=None, source=None, search=None, include_inactive=False,
include_containers=True):
"""Shared filter logic for the list endpoint and the CSV export.
Kept in one place so the two views can never drift apart. Default shows
@@ -483,6 +525,12 @@ def _apply_asset_filters(query, db, status=None, source=None, search=None, inclu
# so the pin column is the signal - same as vCenter and Intune.
query = query.filter(
Asset.igel_unit_id.isnot(None) | (Asset.source == AssetSource.IGEL))
elif source == AssetSource.NETDISCO:
# A switch carries no findings until the first firmware scan, and
# one another source registered first keeps that source - so the
# pin column is the signal, same as vCenter and IGEL.
query = query.filter(
Asset.netdisco_device_ip.isnot(None) | (Asset.source == AssetSource.NETDISCO))
elif source == AssetSource.MANUAL:
# genuinely manual = no scanner linkage AND no scanner-sourced vuln
query = query.filter(
@@ -490,10 +538,14 @@ def _apply_asset_filters(query, db, status=None, source=None, search=None, inclu
Asset.intune_device_id.is_(None), Asset.defender_machine_id.is_(None),
Asset.vmware_uuid.is_(None), Asset.source != AssetSource.VCENTER,
Asset.igel_unit_id.is_(None), Asset.source != AssetSource.IGEL,
Asset.netdisco_device_ip.is_(None), Asset.source != AssetSource.NETDISCO,
Asset.source != AssetSource.CONTAINER,
~_has_vuln_source("wazuh"), ~_has_vuln_source("nessus"),
~_has_vuln_source("intune"), ~_has_vuln_source("defender"))
else:
query = query.filter(Asset.source == source)
elif not include_containers:
query = query.filter(Asset.source != AssetSource.CONTAINER)
if search:
search_pattern = f"%{search}%"
@@ -506,11 +558,12 @@ def _apply_asset_filters(query, db, status=None, source=None, search=None, inclu
@router.get("", response_model=List[AssetResponse])
async def list_assets(
def list_assets(
status: Optional[AssetStatus] = Query(None),
source: Optional[AssetSource] = Query(None),
search: Optional[str] = Query(None, description="Suche in Hostname, IP"),
include_inactive: bool = Query(False, description="Also include DECOMMISSIONED assets (INACTIVE are shown by default)"),
include_containers: bool = Query(True, description="false hides container images (unless source=CONTAINER)"),
sort_by: str = Query("hostname", description="hostname, ip_address, operating_system, status, last_scan, network_exposure_score, policy_name, assigned_user_name"),
sort_order: str = Query("asc", description="asc, desc"),
limit: int = Query(100, le=1000),
@@ -530,7 +583,8 @@ async def list_assets(
"""
query = db.query(Asset)
query = _apply_asset_filters(query, db, status=status, source=source,
search=search, include_inactive=include_inactive)
search=search, include_inactive=include_inactive,
include_containers=include_containers)
# Whitelist-driven ORDER BY. Always map to a real SQLAlchemy column;
# never pass `sort_by` straight to .order_by() (SQL-injection surface).
@@ -577,20 +631,40 @@ async def list_assets(
assets = query.offset(offset).limit(limit).all()
# Vulnerability-Counts + assigned user info
results = []
# Vulnerability-Counts + assigned user info.
#
# ONE grouped query, not one COUNT per asset. This loop used to issue a
# separate `SELECT count(*) FROM vulnerabilities WHERE asset_id = ?` per
# row, so a page of 1000 assets meant 1000 round trips and the request
# took the better part of a minute (observed in the backend log:
# GET /api/v1/assets?limit=1000 dominating the page load).
counts = {}
if assets:
counts = dict(
db.query(Vulnerability.asset_id, func.count(Vulnerability.id))
.filter(Vulnerability.asset_id.in_([a.id for a in assets]),
Vulnerability.status == VulnerabilityStatus.open)
.group_by(Vulnerability.asset_id).all())
# Docker host ↔ image links, both directions in one query.
image_counts, runs_on = {}, {}
if assets:
ids = [a.id for a in assets]
for image_id, host_id, host_name in (
db.query(ContainerPlacement.image_asset_id, Asset.id, Asset.hostname)
.join(Asset, Asset.id == ContainerPlacement.host_asset_id)
.filter(ContainerPlacement.host_asset_id.in_(ids)
| ContainerPlacement.image_asset_id.in_(ids))
.order_by(func.lower(Asset.hostname))):
image_counts[host_id] = image_counts.get(host_id, 0) + 1
runs_on.setdefault(image_id, []).append({"id": host_id, "hostname": host_name})
for asset in assets:
vuln_count = db.query(Vulnerability).filter(
Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open
).count()
asset.vulnerability_count = vuln_count
asset.vulnerability_count = counts.get(asset.id, 0)
asset.container_image_count = image_counts.get(asset.id, 0)
asset.runs_on = runs_on.get(asset.id, [])
asset.assigned_user_name = asset.assigned_user.username if asset.assigned_user else None
asset.policy_name = asset.policy.name if asset.policy else None
results.append(asset)
return results
return assets
def _csv_json_list(raw):
@@ -641,12 +715,41 @@ def _fmt_risk_dimensions(dims):
return " | ".join(parts)
@router.get("/names")
def list_asset_names(
include_inactive: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Every asset as {id, hostname} — for host pickers. No cap, no counts.
The host dropdown on the Vulnerabilities page used to fetch the full asset
list at `limit=1000`, which is the endpoint's hard maximum. With 1194
assets in the estate the list simply stopped at the letter "M" and the
remaining 194 hosts could not be selected at all a silent cap on a
picker, which is the worst place for one.
Fetching the full rows was also far more than a picker needs: a whole
AssetResponse each, with the open-vulnerability count behind it. Two
columns, one query, ordered the same way the table is (case-insensitively,
so "alpine" does not outrank "Webserver").
Declared BEFORE the `/{asset_id}` route on purpose FastAPI matches in
declaration order, and "names" would otherwise be read as an asset id.
"""
q = _apply_asset_filters(db.query(Asset.id, Asset.hostname), db,
include_inactive=include_inactive)
rows = q.order_by(func.lower(Asset.hostname)).all()
return [{"id": r.id, "hostname": r.hostname} for r in rows]
@router.get("/export")
def export_assets_csv(
status: Optional[AssetStatus] = Query(None),
source: Optional[AssetSource] = Query(None),
search: Optional[str] = Query(None, description="Suche in Hostname, IP"),
include_inactive: bool = Query(False, description="Also include DECOMMISSIONED assets (INACTIVE are shown by default)"),
include_containers: bool = Query(True, description="false hides container images (unless source=CONTAINER)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -658,7 +761,8 @@ def export_assets_csv(
"""
query = db.query(Asset)
query = _apply_asset_filters(query, db, status=status, source=source,
search=search, include_inactive=include_inactive)
search=search, include_inactive=include_inactive,
include_containers=include_containers)
# Eager-load the related columns the CSV reads (policy / assignee / groups)
# so a full export does not fall back to a lazy SELECT per row under
# yield_per. selectinload is yield_per-safe (joinedload is not).
@@ -695,6 +799,8 @@ def export_assets_csv(
"Wazuh Agent ID", "Nessus Host UUID", "Intune Device ID",
"Defender Machine ID", "AAD Device ID", "VMware UUID",
"Created At", "Updated At",
"Intune Serial", "IGEL Unit ID", "IGEL Serial",
"Netdisco Device IP", "Netdisco Serial", "VMware Serial",
])
yield buf.getvalue()
buf.seek(0); buf.truncate(0)
@@ -735,6 +841,12 @@ def export_assets_csv(
asset.vmware_uuid or "",
_fmt_dt(asset.created_at),
_fmt_dt(asset.updated_at),
asset.intune_serial or "",
asset.igel_unit_id or "",
asset.igel_serial or "",
asset.netdisco_device_ip or "",
asset.netdisco_serial or "",
asset.vmware_serial or "",
])
yield buf.getvalue()
buf.seek(0); buf.truncate(0)
@@ -748,7 +860,7 @@ def export_assets_csv(
@router.get("/{asset_id}", response_model=AssetResponse)
async def get_asset_detail(
def get_asset_detail(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
@@ -772,7 +884,7 @@ async def get_asset_detail(
@router.post("", status_code=status.HTTP_201_CREATED, response_model=AssetResponse)
async def create_asset(
def create_asset(
asset_data: AssetCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -780,26 +892,12 @@ async def create_asset(
"""
Erstellt manuelles Asset (für Systeme ohne Wazuh-Agent)
"""
# Check for duplicate hostname
existing_hostname = db.query(Asset).filter(
Asset.hostname == asset_data.hostname
).first()
if existing_hostname:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Asset with this hostname already exists"
)
# Check for duplicate IP (only if IP is provided)
if asset_data.ip_address:
existing_ip = db.query(Asset).filter(
Asset.ip_address == asset_data.ip_address
).first()
if existing_ip:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Asset with this IP address already exists"
)
# Kein Duplikat-Check auf Hostname oder IP-Adresse: beides sind keine
# Identifikatoren. Ab Werk vergebene Namen ("RackPDU") stehen mehrfach im
# Bestand; Gastnetze, VPN-Pools, VLAN-Segmente und recycelte DHCP-Leases
# fuehren dieselbe Adresse mehrfach. Der Operator konnte die betroffenen
# Systeme sonst gar nicht erst anlegen. Was ein Asset identifiziert, steht
# in services/asset_matching.py.
# Erstelle Asset
asset = Asset(
@@ -829,7 +927,7 @@ async def create_asset(
@router.put("/{asset_id}", response_model=AssetResponse)
async def update_asset(
def update_asset(
asset_id: int,
update_data: AssetUpdateRequest,
db: Session = Depends(get_db),
@@ -899,7 +997,7 @@ async def update_asset(
@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_asset(
def delete_asset(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
@@ -957,7 +1055,7 @@ async def delete_asset(
@router.patch("/{asset_id}/assign")
async def assign_asset(
def assign_asset(
asset_id: int,
assign_data: AssetAssignRequest,
db: Session = Depends(get_db),
@@ -998,7 +1096,7 @@ async def assign_asset(
@router.get("/{asset_id}/vulnerabilities")
async def get_asset_vulnerabilities(
def get_asset_vulnerabilities(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
@@ -1030,6 +1128,7 @@ async def get_asset_vulnerabilities(
import json
from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
from app.models.setting import Setting
from app.services.asset_matching import pick_one
@router.post("/sync_wazuh", response_model=dict)
def sync_wazuh_assets(
@@ -1039,7 +1138,19 @@ def sync_wazuh_assets(
"""
Synchronizes assets from Wazuh Manager.
fetch active agents -> update/create local assets.
Recorded in the sync-run ledger as the "assets" phase; when the
scheduled scan calls this, the record merges into that run.
"""
from app.services.sync_run_service import record_sync_run
with record_sync_run("wazuh") as run:
run.stats.setdefault("phase", "assets")
stats = _sync_wazuh_assets(db)
run.stats.update(stats)
return stats
def _sync_wazuh_assets(db: Session) -> dict:
# 1. Get Wazuh Config (transparently decrypted)
from app.auth.setting_crypto import read_setting_value
raw_wazuh = read_setting_value(db, "wazuh_config")
@@ -1085,6 +1196,7 @@ def sync_wazuh_assets(
# not the TCP connection's.
from app.services.asset_lifecycle import (
apply_status,
created,
parse_source_timestamp,
status_for_last_seen,
threshold_days,
@@ -1103,6 +1215,9 @@ def sync_wazuh_assets(
stats["total_from_wazuh"] = len(agents)
stats["never_connected_skipped"] = 0
# Every agent id this run reports — the discriminator for the
# hostname fallback below.
live_agent_ids = {a.get("id") for a in agents if a.get("id")}
for agent in agents:
agent_id = agent.get("id")
@@ -1129,7 +1244,22 @@ def sync_wazuh_assets(
if not asset:
# Check by hostname to avoid duplicates if manually added
asset = db.query(Asset).filter(Asset.hostname == hostname).first()
# …but only if the name is free. This stage is what keeps
# a re-registered agent on its asset: authd's <force>
# replaces an agent of the same name (default since 4.3),
# the host comes back under a NEW id, and the name is the
# only way back to its history. A replaced agent is gone
# from the list, so its id is not live — while two agents
# that are BOTH in this run are two hosts, and adopting
# the first one's asset would move the second one's
# findings onto it and take the id away (two APC PDUs, both
# named "RackPDU" from the factory, is all it takes).
# Several free same-named assets: the address decides, or
# the agent gets its own (pick_one).
free = [a for a in db.query(Asset)
.filter(Asset.hostname == hostname).order_by(Asset.id)
if not (a.wazuh_agent_id and a.wazuh_agent_id in live_agent_ids)]
asset = pick_one(free, ip, hostname)
if asset:
# Update
@@ -1186,6 +1316,7 @@ def sync_wazuh_assets(
new_asset.groups.append(default_group)
db.add(new_asset)
created(db, new_asset, "Wazuh")
stats["created"] += 1
# The manager nodes themselves. They run no agent — they
@@ -1225,7 +1356,11 @@ def sync_wazuh_assets(
seen_wazuh_agent_ids.add(nid)
a = db.query(Asset).filter(Asset.wazuh_agent_id == nid).first()
if not a:
a = db.query(Asset).filter(Asset.hostname == node["name"]).first()
# Never an agent's asset: node names ship as "node01",
# and taking it overwrote a real agent's id.
a = (db.query(Asset).filter(Asset.hostname == node["name"],
Asset.wazuh_agent_id.is_(None))
.order_by(Asset.id).first())
label = f"Wazuh {node.get('type') or 'node'} node"
if a:
a.wazuh_agent_id = nid
@@ -1238,13 +1373,15 @@ def sync_wazuh_assets(
"Wazuh manager node reported by the cluster sync")
stats["updated"] += 1
else:
db.add(Asset(hostname=node["name"], ip_address=node.get("ip"),
wazuh_agent_id=nid, operating_system=label,
os_version=node.get("version"),
source=AssetSource.WAZUH,
status=AssetStatus.ACTIVE,
last_seen=datetime.now(),
last_seen_source="wazuh"))
a = Asset(hostname=node["name"], ip_address=node.get("ip"),
wazuh_agent_id=nid, operating_system=label,
os_version=node.get("version"),
source=AssetSource.WAZUH,
status=AssetStatus.ACTIVE,
last_seen=datetime.now(),
last_seen_source="wazuh")
db.add(a)
created(db, a, "Wazuh")
stats["created"] += 1
# Flush inside the guard: the agent loop's commit comes
# after this block, so anything rejected by the database here
@@ -1276,7 +1413,9 @@ def sync_wazuh_assets(
except Exception as e:
logger.error(f"Wazuh Sync Failed: {e}")
raise HTTPException(status_code=500, detail="Wazuh sync failed. Check server logs for details.")
# The real reason goes to the caller (editors only): "check the
# server logs" left an operator with a failing Wazuh API for a day.
raise HTTPException(status_code=502, detail=f"Wazuh sync failed: {e}")
return stats
+1 -1
View File
@@ -99,7 +99,7 @@ _SORT_COLUMNS = {
@router.get("/logs", response_model=List[AuditLogResponse])
async def get_audit_logs(
def get_audit_logs(
response: Response,
skip: int = 0,
limit: int = Query(100, le=1000, description="Max rows per page (cap 1000)"),
+22 -20
View File
@@ -294,7 +294,7 @@ def _issue_forced_setup_challenge(user: User) -> JSONResponse:
@router.post("/login", response_model=LoginResponse)
@limiter.limit("5/minute")
async def login(
def login(
request: Request,
credentials: LoginRequest,
db: Session = Depends(get_db)
@@ -321,6 +321,8 @@ async def login(
result = orchestrator.authenticate_with_credentials(
credentials.username, credentials.password,
actor_ip=ip, actor_user_agent=ua,
# Browsers always send Sec-Fetch-Mode; scripts don't (a claim, not proof).
channel="Web-UI" if request.headers.get("sec-fetch-mode") else "API",
)
except AuthError as e:
raise HTTPException(
@@ -356,7 +358,7 @@ async def login(
@router.post("/mfa/verify", response_model=LoginResponse)
@limiter.limit("10/minute")
async def mfa_verify(
def mfa_verify(
request: Request,
payload: MfaVerifyRequest,
db: Session = Depends(get_db)
@@ -405,7 +407,7 @@ async def mfa_verify(
@router.get("/providers")
async def list_auth_providers():
def list_auth_providers():
"""
Public endpoint: lists enabled auth providers so the frontend can render
the correct mix of login buttons (local form, LDAP shares the local form,
@@ -436,7 +438,7 @@ async def list_auth_providers():
@router.post("/mfa/setup", response_model=MfaSetupResponse)
@limiter.limit("5/minute")
async def mfa_setup(
def mfa_setup(
request: Request,
payload: MfaSetupRequest,
db: Session = Depends(get_db),
@@ -480,7 +482,7 @@ async def mfa_setup(
@router.post("/mfa/activate")
@limiter.limit("10/minute")
async def mfa_activate(
def mfa_activate(
request: Request,
payload: MfaActivateRequest,
db: Session = Depends(get_db),
@@ -516,7 +518,7 @@ async def mfa_activate(
@router.post("/mfa/disable")
@limiter.limit("5/minute")
async def mfa_disable(
def mfa_disable(
request: Request,
payload: MfaSetupRequest,
db: Session = Depends(get_db),
@@ -564,7 +566,7 @@ def _decode_setup_token(setup_token: str, db: Session) -> User:
@router.post("/mfa/forced-setup/start", response_model=MfaSetupResponse)
@limiter.limit("5/minute")
async def mfa_forced_setup_start(
def mfa_forced_setup_start(
request: Request,
payload: MfaForcedSetupStartRequest,
db: Session = Depends(get_db),
@@ -599,7 +601,7 @@ async def mfa_forced_setup_start(
@router.post("/mfa/forced-setup/activate", response_model=LoginResponse)
@limiter.limit("10/minute")
async def mfa_forced_setup_activate(
def mfa_forced_setup_activate(
request: Request,
payload: MfaForcedSetupActivateRequest,
db: Session = Depends(get_db),
@@ -640,7 +642,7 @@ async def mfa_forced_setup_activate(
@router.post("/logout")
async def logout(
def logout(
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
@@ -687,7 +689,7 @@ async def logout(
@router.post("/refresh", response_model=LoginResponse)
async def refresh_token(
def refresh_token(
request: Request,
refresh_request: Optional[RefreshTokenRequest] = None,
db: Session = Depends(get_db)
@@ -762,7 +764,7 @@ async def refresh_token(
@router.post("/setup-admin")
@limiter.limit("3/minute")
async def setup_admin(
def setup_admin(
request: Request,
setup: SetupAdminRequest,
db: Session = Depends(get_db)
@@ -829,7 +831,7 @@ async def setup_admin(
@router.get("/me")
async def get_current_user_info(current_user: User = Depends(get_current_user)):
def get_current_user_info(current_user: User = Depends(get_current_user)):
"""
Returns information about current user
"""
@@ -847,7 +849,7 @@ async def get_current_user_info(current_user: User = Depends(get_current_user)):
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register_user(
def register_user(
request: Request,
user_data: RegisterRequest,
db: Session = Depends(get_db),
@@ -914,7 +916,7 @@ async def register_user(
@router.post("/change-password")
async def change_password(
def change_password(
request: Request,
password_data: PasswordChangeRequest,
current_user: User = Depends(get_current_user),
@@ -962,7 +964,7 @@ class AdminResetPasswordRequest(BaseModel):
@router.post("/users/{user_id}/reset-mfa")
async def admin_reset_mfa(
def admin_reset_mfa(
request: Request,
user_id: int,
current_user: User = Depends(RequireAdmin),
@@ -1001,7 +1003,7 @@ async def admin_reset_mfa(
@router.post("/users/{user_id}/reset-password")
async def admin_reset_password(
def admin_reset_password(
request: Request,
user_id: int,
password_data: AdminResetPasswordRequest,
@@ -1044,7 +1046,7 @@ async def admin_reset_password(
@router.get("/users")
async def list_users(
def list_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(RequireAdmin),
@@ -1071,7 +1073,7 @@ async def list_users(
@router.post("/users/{user_id}/unlock")
@limiter.limit("10/minute")
async def unlock_user(
def unlock_user(
request: Request,
user_id: int,
current_user: User = Depends(RequireAdmin),
@@ -1106,7 +1108,7 @@ class UserUpdateRequest(BaseModel):
@router.patch("/users/{user_id}")
async def update_user(
def update_user(
request: Request,
user_id: int,
user_data: UserUpdateRequest,
@@ -1165,7 +1167,7 @@ async def update_user(
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
def delete_user(
request: Request,
user_id: int,
current_user: User = Depends(RequireAdmin),
+6 -6
View File
@@ -107,7 +107,7 @@ def _audit(db: Session, user_id: int, event: AuditEventType, desc: str, request:
# ============================================
@router.get("/status")
async def auth_status(
def auth_status(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
@@ -173,7 +173,7 @@ def _provider_is_configured(provider_name: str) -> bool:
# ============================================
@router.get("/role-mappings")
async def get_role_mappings(
def get_role_mappings(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
@@ -188,7 +188,7 @@ async def get_role_mappings(
@router.put("/role-mappings")
async def put_role_mappings(
def put_role_mappings(
payload: RoleMappingsPayload,
request: Request,
db: Session = Depends(get_db),
@@ -229,7 +229,7 @@ async def put_role_mappings(
# ============================================
@router.post("/ldap/test")
async def test_ldap(
def test_ldap(
payload: LdapTestRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
@@ -278,7 +278,7 @@ async def test_ldap(
@router.post("/oidc/test")
async def test_oidc(
def test_oidc(
current_user: User = Depends(RequireAdmin),
):
"""Fetch OIDC discovery doc + JWKS, sanity-check fields."""
@@ -314,7 +314,7 @@ async def test_oidc(
@router.post("/saml/test")
async def test_saml(
def test_saml(
current_user: User = Depends(RequireAdmin),
):
"""Fetch IdP metadata, return SSO URL + entity ID + cert thumbprint hint."""
+1 -1
View File
@@ -157,7 +157,7 @@ def _validate_id_token(id_token: str, strategy: OidcStrategy, expected_nonce: st
# ============================================
@router.get("/login")
async def oidc_login(request: Request):
def oidc_login(request: Request):
"""
SP-initiated login. Stash state+nonce+PKCE verifier in a signed cookie,
then 302 to the IdP's authorize endpoint.
+2 -2
View File
@@ -122,7 +122,7 @@ def _prepare_request(request: Request, form: dict) -> dict:
@router.get("/login")
async def saml_login(request: Request):
def saml_login(request: Request):
"""SP-initiated: build AuthnRequest and redirect to IdP."""
from onelogin.saml2.auth import OneLogin_Saml2_Auth
@@ -230,7 +230,7 @@ async def saml_acs(request: Request, db: Session = Depends(get_db)):
@router.get("/metadata")
async def saml_metadata():
def saml_metadata():
"""Returns SP metadata XML for the IdP administrator."""
from onelogin.saml2.settings import OneLogin_Saml2_Settings
+86
View File
@@ -0,0 +1,86 @@
"""Dockhand integration HTTP endpoints.
- POST /api/v1/integrations/dockhand/test token/connectivity probe
- POST /api/v1/integrations/dockhand/sync trigger findings sync
- GET /api/v1/integrations/dockhand/sync/status poll target for the GUI
Config lives in the settings table under `dockhand_config` (encrypted JSON:
base_url, token, verify_ssl, min_severity, only_fixed, scan_nightly).
"""
from __future__ import annotations
import logging
import threading
import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.database import SessionLocal, get_db
from app.models.user import User
from app.services import dockhand_service
from app.services.sync_run_service import record_sync_run
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/integrations/dockhand", tags=["Dockhand"])
# ponytail: module-level state assumes the single uvicorn worker we ship with,
# same as the Netdisco router; move to the DB if --workers is ever added.
_DH_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test")
def test_dockhand(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
cfg = dockhand_service.load_dockhand_config(db)
if not cfg:
raise HTTPException(400, "Dockhand is not configured (URL missing).")
return dockhand_service.test_connection(cfg)
def _run_dockhand_sync_threaded() -> None:
db = SessionLocal()
try:
with record_sync_run("dockhand", "manual") as run:
stats = dockhand_service.run_dockhand_sync(db)
run.stats.update(stats)
_DH_SYNC["result"] = stats
_DH_SYNC["error"] = None
except Exception as e:
_DH_SYNC["result"] = None
_DH_SYNC["error"] = str(e)
logger.error("Dockhand sync (manual) failed: %s", e)
finally:
db.close()
_DH_SYNC["running"] = False
_DH_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202)
def sync_dockhand(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Kick off a Dockhand sync in the background (202). Poll GET /sync/status."""
if not dockhand_service.load_dockhand_config(db):
raise HTTPException(400, "Dockhand is not configured.")
if _DH_SYNC["running"]:
return {"status": "already_running", "detail": "A Dockhand sync is already in progress."}
_DH_SYNC.update({"running": True, "result": None, "error": None,
"started_at": time.time(), "finished_at": None})
threading.Thread(target=_run_dockhand_sync_threaded, daemon=True).start()
return {"status": "started", "detail": "Dockhand sync started in the background."}
@router.get("/sync/status")
def sync_dockhand_status(current_user: User = Depends(RequireEditor)):
s = _DH_SYNC
state = ("running" if s["running"] else "error" if s["error"]
else "done" if s["result"] else "idle")
return {"state": state, "running": s["running"], "result": s["result"],
"error": s["error"], "finished_at": s["finished_at"]}
+13 -9
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel
from datetime import datetime
from app.database import get_db
from app.models.user import User
from app.models.user import User, UserRole
from app.models.group import Group, user_groups
from app.models.setting import Setting
from app.auth.dependencies import get_current_user, RequireAdmin
@@ -41,12 +41,14 @@ class GroupResponse(BaseModel):
# --- Endpoints ---
@router.get("", response_model=List[GroupResponse])
async def list_groups(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
def list_groups(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
groups = db.query(Group).all()
results = []
# Every role needs the group list (asset / vulnerability filters), but the
# membership is user data — /auth/users is admin-only, so this is too.
is_admin = current_user.role == UserRole.ADMIN
for group in groups:
# Fetch members manually or via relationship
members = [{"id": u.id, "username": u.username} for u in group.users]
members = [{"id": u.id, "username": u.username} for u in group.users] if is_admin else []
results.append({
"id": group.id,
"name": group.name,
@@ -58,7 +60,7 @@ async def list_groups(db: Session = Depends(get_db), current_user: User = Depend
return results
@router.post("", response_model=GroupResponse)
async def create_group(
def create_group(
group_in: GroupCreate,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
@@ -93,7 +95,7 @@ async def create_group(
}
@router.put("/{group_id}", response_model=GroupResponse)
async def update_group(
def update_group(
group_id: int,
group_in: GroupUpdate,
db: Session = Depends(get_db),
@@ -130,7 +132,7 @@ async def update_group(
}
@router.delete("/{group_id}")
async def delete_group(
def delete_group(
group_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
@@ -155,14 +157,16 @@ async def delete_group(
# --- Settings ---
@router.get("/default", response_model=Optional[int])
async def get_default_group(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
def get_default_group(db: Session = Depends(get_db), current_user: User = Depends(RequireAdmin)):
# Only the Groups page asks — and that page is admin-only. Its 403 is what
# bounces a non-admin who typed the URL.
setting = db.query(Setting).filter(Setting.key == "default_group_id").first()
if setting and setting.value:
return int(setting.value)
return None
@router.post("/default")
async def set_default_group(
def set_default_group(
group_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
+7 -4
View File
@@ -17,6 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
from app.services.igel_service import (
@@ -35,7 +36,7 @@ _IGEL_SYNC: dict = {"running": False, "result": None, "error": None,
@router.post("/test")
async def test_igel(
def test_igel(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
@@ -61,7 +62,9 @@ async def test_igel(
def _run_igel_sync_threaded() -> None:
db = SessionLocal()
try:
stats = run_igel_sync(db)
with record_sync_run("igel", "manual") as run:
stats = run_igel_sync(db)
run.stats.update(stats)
result = {k: v for k, v in stats.items() if k != "errors"}
_IGEL_SYNC["result"] = result
_IGEL_SYNC["error"] = None
@@ -77,7 +80,7 @@ def _run_igel_sync_threaded() -> None:
@router.post("/sync", status_code=202)
async def sync_igel(
def sync_igel(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
@@ -97,7 +100,7 @@ async def sync_igel(
@router.get("/sync/status")
async def sync_igel_status(current_user: User = Depends(RequireEditor)):
def sync_igel_status(current_user: User = Depends(RequireEditor)):
"""Poll target for the GUI: current/last manual-sync state + result stats."""
s = _IGEL_SYNC
state = ("running" if s["running"]
+7 -4
View File
@@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
from app.services.intune_service import load_intune_config, run_intune_sync, _build_client
@@ -35,7 +36,7 @@ _INTUNE_SYNC: dict = {"running": False, "result": None, "error": None,
@router.post("/test")
async def test_intune(
def test_intune(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
@@ -53,7 +54,9 @@ async def test_intune(
def _run_intune_sync_threaded() -> None:
db = SessionLocal()
try:
stats = run_intune_sync(db)
with record_sync_run("intune", "manual") as run:
stats = run_intune_sync(db)
run.stats.update(stats)
result = {k: v for k, v in stats.items() if k != "errors"}
_INTUNE_SYNC["result"] = result
_INTUNE_SYNC["error"] = None
@@ -69,7 +72,7 @@ def _run_intune_sync_threaded() -> None:
@router.post("/sync", status_code=202)
async def sync_intune(
def sync_intune(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
@@ -88,7 +91,7 @@ async def sync_intune(
@router.get("/sync/status")
async def sync_intune_status(current_user: User = Depends(RequireEditor)):
def sync_intune_status(current_user: User = Depends(RequireEditor)):
"""Poll target for the GUI: current/last manual-sync state + result stats."""
s = _INTUNE_SYNC
state = ("running" if s["running"]
+7 -4
View File
@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.services.sync_run_service import record_sync_run
from app.database import get_db
from app.integrations.nessus_client import (
NessusAPIError,
@@ -152,7 +153,9 @@ def trigger_nessus_sync(
"""
import httpx as _httpx
try:
stats = run_nessus_sync(db, scan_ids=payload.scan_ids)
with record_sync_run("nessus", "manual") as run:
stats = run_nessus_sync(db, scan_ids=payload.scan_ids)
run.stats.update(stats)
return {"message": "Nessus sync completed", **stats}
except RuntimeError as e:
# config missing
@@ -287,7 +290,7 @@ def nessus_scan_host(
@router.post("/scan-and-import")
async def nessus_scan_and_import(
def nessus_scan_and_import(
payload: NessusScanImportRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
@@ -311,7 +314,7 @@ async def nessus_scan_and_import(
@router.get("/scan-and-import/status/{job_id}")
async def nessus_scan_and_import_status(
def nessus_scan_and_import_status(
job_id: str,
current_user: User = Depends(RequireEditor),
):
@@ -324,7 +327,7 @@ async def nessus_scan_and_import_status(
@router.post("/cleanup-pseudo-cves")
async def cleanup_pseudo_cves(
def cleanup_pseudo_cves(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
+104
View File
@@ -0,0 +1,104 @@
"""Netdisco integration HTTP endpoints.
- POST /api/v1/integrations/netdisco/test auth/connectivity probe
- POST /api/v1/integrations/netdisco/sync trigger inventory sync
- GET /api/v1/integrations/netdisco/sync/status poll target for the GUI
Config lives in the settings table under `netdisco_config` (encrypted JSON:
host, port, use_https, verify_ssl, api_key, username, password,
auto_create_assets).
"""
from __future__ import annotations
import logging
import threading
import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
from app.services.netdisco_service import (
_build_client, load_netdisco_config, run_netdisco_sync,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/integrations/netdisco", tags=["Netdisco"])
# Last/current manual-sync state so the GUI can poll for completion (the sync
# itself is fire-and-forget 202). ponytail: module-level state assumes the
# single uvicorn worker we ship with; if you add --workers, move this to the DB.
_ND_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test")
def test_netdisco(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
"""Verify the Netdisco connection: login (if any), then the inventory read."""
cfg = load_netdisco_config(db)
if not cfg:
raise HTTPException(400, "Netdisco is not configured (host missing).")
client = _build_client(cfg)
try:
return client.test_connection()
finally:
client.close()
def _run_netdisco_sync_threaded() -> None:
db = SessionLocal()
try:
with record_sync_run("netdisco", "manual") as run:
stats = run_netdisco_sync(db)
run.stats.update(stats)
result = {k: v for k, v in stats.items() if k != "errors"}
_ND_SYNC["result"] = result
_ND_SYNC["error"] = None
logger.info("Netdisco sync (manual) done: %s", result)
except Exception as e:
_ND_SYNC["result"] = None
_ND_SYNC["error"] = str(e)
logger.error("Netdisco sync (manual) failed: %s", e)
finally:
db.close()
_ND_SYNC["running"] = False
_ND_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202)
def sync_netdisco(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Kick off a Netdisco inventory sync in the background (202).
Fire-and-forget so a large estate can't trip the reverse-proxy request
timeout a campus Netdisco holds thousands of devices. Poll GET
/sync/status."""
if not load_netdisco_config(db):
raise HTTPException(400, "Netdisco is not configured.")
if _ND_SYNC["running"]:
return {"status": "already_running", "detail": "A Netdisco sync is already in progress."}
_ND_SYNC.update({"running": True, "result": None, "error": None,
"started_at": time.time(), "finished_at": None})
threading.Thread(target=_run_netdisco_sync_threaded, daemon=True).start()
return {"status": "started", "detail": "Netdisco sync started in the background."}
@router.get("/sync/status")
def sync_netdisco_status(current_user: User = Depends(RequireEditor)):
"""Poll target for the GUI: current/last manual-sync state + result stats."""
s = _ND_SYNC
state = ("running" if s["running"]
else "error" if s["error"]
else "done" if s["result"]
else "idle")
return {"state": state, "running": s["running"], "result": s["result"],
"error": s["error"], "finished_at": s["finished_at"]}
+5 -2
View File
@@ -5,7 +5,7 @@ Endpoints for notification log and email tests.
"""
from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import desc
from pydantic import BaseModel
@@ -47,7 +47,8 @@ class TestEmailRequest(BaseModel):
@router.get("/log", response_model=List[NotificationLogResponse])
async def list_notification_logs(
def list_notification_logs(
response: Response,
notification_type: Optional[str] = Query(None),
limit: int = Query(50, le=500),
offset: int = Query(0),
@@ -73,6 +74,8 @@ async def list_notification_logs(
except ValueError:
pass
# Matched rows before paging, so the GUI can show "Page x / y".
response.headers["X-Total-Count"] = str(query.count())
logs = query.offset(offset).limit(limit).all()
results = []
+7 -6
View File
@@ -50,18 +50,19 @@ class PolicyResponse(PolicyBase):
# --- Endpoints ---
@router.get("", response_model=List[PolicyResponse])
async def list_policies(
def list_policies(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(RequireEditor)
):
"""List all policies"""
"""List all policies (editor+ — the Policies page is, and readonly has no
policy dropdown anywhere)."""
policies = db.query(Policy).all()
# TODO: Calculate real compliance per policy
# For now, return mock or simple count (mocking generic 100% for start)
return policies
@router.post("", response_model=PolicyResponse, status_code=status.HTTP_201_CREATED)
async def create_policy(
def create_policy(
policy_data: PolicyCreate,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
@@ -74,7 +75,7 @@ async def create_policy(
return policy
@router.put("/{policy_id}", response_model=PolicyResponse)
async def update_policy(
def update_policy(
policy_id: int,
policy_data: PolicyUpdate,
db: Session = Depends(get_db),
@@ -93,7 +94,7 @@ async def update_policy(
return policy
@router.delete("/{policy_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_policy(
def delete_policy(
policy_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
+4 -4
View File
@@ -22,7 +22,7 @@ from app.services.report_scope import scoped as _scoped
@router.get("/vulnerabilities/csv")
async def export_vulnerabilities_csv(
def export_vulnerabilities_csv(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
@@ -72,7 +72,7 @@ async def export_vulnerabilities_csv(
@router.get("/executive-summary/pdf")
async def export_executive_summary_pdf(
def export_executive_summary_pdf(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
@@ -178,7 +178,7 @@ async def export_executive_summary_pdf(
@router.get("/patching-progress/pdf")
async def export_patching_progress_pdf(
def export_patching_progress_pdf(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
@@ -249,7 +249,7 @@ async def export_patching_progress_pdf(
@router.get("/compliance/iso27001/pdf")
async def export_compliance_audit_pdf(
def export_compliance_audit_pdf(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
+57 -12
View File
@@ -58,12 +58,12 @@ class ScheduleResponse(BaseModel):
# --- Endpoints ---
@router.get("", response_model=List[ScanResponse])
async def list_scans(
def list_scans(
status: Optional[ScanStatus] = Query(None),
asset_id: Optional[int] = Query(None),
limit: int = Query(50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(RequireEditor)
):
"""List scans with optional filtering"""
query = db.query(Scan)
@@ -123,10 +123,10 @@ class ScanRunSummary(BaseModel):
@router.get("/summary", response_model=List[ScanRunSummary])
async def list_scan_summaries(
def list_scan_summaries(
limit: int = Query(20),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(RequireEditor)
):
"""
Returns scan runs grouped by time window (scans triggered within 5 seconds = one run).
@@ -199,7 +199,7 @@ class ScanDeleteRequest(BaseModel):
@router.post("/clear", response_model=dict)
async def clear_scans(
def clear_scans(
body: ScanDeleteRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -235,7 +235,26 @@ def trigger_autoscan(
):
"""
Trigger automated scans for ALL assets connected to Wazuh.
Recorded in the sync-run ledger; a run where every agent failed
(authentication refused) counts as failed, not as 0 triggered.
"""
from app.services.sync_run_service import record_sync_run, fail_if_source_broken
with record_sync_run("wazuh") as run:
run.stats["phase"] = "vulnerabilities"
result = _autoscan(db)
run.stats.update({"agents_synced": result.get("scans_triggered", 0),
"errors": result.get("errors", [])})
# Every agent failed, or one of them failed the way a dead API fails:
# a failed run, not "completed with errors".
fail_if_source_broken(run, result.get("scans_triggered", 0), result.get("errors", []))
# Every agent answered without a single CVE — the indexer is down or
# empty even though the manager API let us in.
if run.error is None and result.get("outage"):
run.fail(f"Wazuh {result['outage']}")
return result
def _autoscan(db: Session) -> dict:
# 1. Get Wazuh Configuration from DB (transparently decrypted)
from app.auth.setting_crypto import read_setting_value
raw_wazuh = read_setting_value(db, "wazuh_config")
@@ -318,12 +337,13 @@ def trigger_autoscan(
scan_obj.error_message = f"Wazuh connection failed: {str(e)}"
scan_obj.completed_at = datetime.now()
db.commit()
raise HTTPException(status_code=500, detail="Failed to connect to Wazuh. Check server logs for details.")
raise HTTPException(status_code=502, detail=f"Failed to connect to Wazuh: {e}")
return {
"message": f"Triggered scans for {triggered_count} assets.",
"scans_triggered": triggered_count,
"errors": errors
"errors": errors,
**({"outage": run_stats["outage"]} if run_stats.get("outage") else {}),
}
@@ -338,7 +358,7 @@ INTERVAL_DELTA_MAP = {
}
@router.get("/schedules", response_model=List[ScheduleResponse])
async def list_schedules(
def list_schedules(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
@@ -348,7 +368,7 @@ async def list_schedules(
@router.post("/schedules", response_model=ScheduleResponse, status_code=status.HTTP_201_CREATED)
async def create_schedule(
def create_schedule(
data: ScheduleCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -370,7 +390,7 @@ async def create_schedule(
@router.put("/schedules/{schedule_id}", response_model=ScheduleResponse)
async def update_schedule(
def update_schedule(
schedule_id: int,
data: ScheduleCreateRequest,
db: Session = Depends(get_db),
@@ -400,7 +420,7 @@ async def update_schedule(
@router.patch("/schedules/{schedule_id}", response_model=ScheduleResponse)
async def toggle_schedule(
def toggle_schedule(
schedule_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -424,7 +444,7 @@ async def toggle_schedule(
@router.delete("/schedules/{schedule_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_schedule(
def delete_schedule(
schedule_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -436,3 +456,28 @@ async def delete_schedule(
db.delete(schedule)
db.commit()
# --- Sync run ledger (every source, manual and scheduled) ---
@router.get("/sync-runs")
def list_sync_runs(
limit: int = Query(30, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Latest sync executions across all sources, newest first."""
from app.services.sync_run_service import list_runs
return list_runs(db, limit)
@router.get("/sync-health")
def get_sync_health(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Per source: last run, last success, and a state the GUI can colour
(ok / failed / stale / running / unconfigured) plus the failure-mail
settings so the panel can edit them in place."""
from app.services.sync_run_service import health_payload
return health_payload(db)
+4 -4
View File
@@ -29,7 +29,7 @@ class SyslogTestRequest(BaseModel):
@router.post("/syslog/test")
async def test_syslog(
def test_syslog(
payload: SyslogTestRequest,
current_user: User = Depends(RequireAdmin),
):
@@ -106,7 +106,7 @@ class SettingUpdate(BaseModel):
# --- Endpoints ---
@router.get("", response_model=List[SettingResponse])
async def list_settings(
def list_settings(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
):
@@ -114,7 +114,7 @@ async def list_settings(
return [_serialize(s) for s in db.query(Setting).all()]
@router.get("/{key}", response_model=SettingResponse)
async def get_setting(
def get_setting(
key: str,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
@@ -126,7 +126,7 @@ async def get_setting(
return _serialize(setting)
@router.put("/{key}", response_model=SettingResponse)
async def update_setting(
def update_setting(
key: str,
update_data: SettingUpdate,
db: Session = Depends(get_db),
+8 -5
View File
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
from app.services.vcenter_service import (
@@ -36,7 +37,7 @@ _VCENTER_SYNC: dict = {"running": False, "result": None, "error": None,
@router.post("/test")
async def test_vcenter(
def test_vcenter(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
@@ -54,7 +55,9 @@ async def test_vcenter(
def _run_vcenter_sync_threaded() -> None:
db = SessionLocal()
try:
stats = run_vcenter_sync(db)
with record_sync_run("vcenter", "manual") as run:
stats = run_vcenter_sync(db)
run.stats.update(stats)
result = {k: v for k, v in stats.items() if k != "errors"}
_VCENTER_SYNC["result"] = result
_VCENTER_SYNC["error"] = None
@@ -70,7 +73,7 @@ def _run_vcenter_sync_threaded() -> None:
@router.post("/sync", status_code=202)
async def sync_vcenter(
def sync_vcenter(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
@@ -89,7 +92,7 @@ async def sync_vcenter(
@router.get("/sync/status")
async def sync_vcenter_status(current_user: User = Depends(RequireEditor)):
def sync_vcenter_status(current_user: User = Depends(RequireEditor)):
"""Poll target for the GUI: current/last manual-sync state + result stats."""
s = _VCENTER_SYNC
state = ("running" if s["running"]
@@ -101,7 +104,7 @@ async def sync_vcenter_status(current_user: User = Depends(RequireEditor)):
@router.post("/catalog/refresh")
async def refresh_build_catalog(
def refresh_build_catalog(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
+247 -31
View File
@@ -5,7 +5,10 @@ Endpoints for CVE management, prioritization, and AI analysis.
"""
from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
import csv
import io
from fastapi import APIRouter, Body, Depends, HTTPException, status, Query, BackgroundTasks
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import desc, asc, func, and_
from sqlalchemy.exc import IntegrityError
@@ -19,7 +22,7 @@ logger = logging.getLogger(__name__)
from app.database import get_db
from app.models.user import User
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
from app.models.asset import Asset, AssetStatus
from app.models.asset import Asset, AssetStatus, ContainerPlacement
from app.models.ai_analysis import AIAnalysis
from app.models.audit_log import AuditLog, AuditEventType
from app.auth.dependencies import get_current_user, RequireEditor
@@ -117,6 +120,8 @@ class VulnerabilityResponse(BaseModel):
deferred_until: Optional[datetime] = None
defer_reason: Optional[str] = None
notification_suppressed: bool = False
# Docker host(s) of a container-image finding, as {id, hostname}.
runs_on: List[dict] = []
# Per-package detail (1:N child rows). Empty for pseudo-CVEs.
packages: List["PackageInfo"] = []
has_fix_any: bool = False # at least one package has a real fix
@@ -150,6 +155,7 @@ class DeferRequest(BaseModel):
class VulnerabilityDetailResponse(VulnerabilityResponse):
description: Optional[str]
cvss_vector: Optional[str]
vendor_severity: Optional[dict] = None
exploit_maturity: Optional[str]
published_date: Optional[datetime]
last_modified_date: Optional[datetime]
@@ -443,6 +449,7 @@ def _build_vuln_response(vuln: Vulnerability) -> dict:
# Fields for DetailResponse (will be None if not detail)
"description": getattr(vuln, 'description', None),
"cvss_vector": getattr(vuln, 'cvss_vector', None),
"vendor_severity": vuln.vendor_severities or None,
"exploit_maturity": getattr(vuln, 'exploit_maturity', None),
"published_date": getattr(vuln, 'published_date', None),
"last_modified_date": getattr(vuln, 'last_modified_date', None),
@@ -461,7 +468,7 @@ def _build_vuln_response(vuln: Vulnerability) -> dict:
# ============================================
@router.post("/bulk-update")
async def bulk_update_vulnerabilities(
def bulk_update_vulnerabilities(
update_data: BulkVulnerabilityUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -544,7 +551,7 @@ async def bulk_update_vulnerabilities(
@router.get("")
async def list_vulnerabilities(
def list_vulnerabilities(
severity: Optional[VulnerabilitySeverity] = Query(None, description="Filter nach Severity"),
status: Optional[str] = Query(None, description="Filter nach Status (enum value or 'all')"),
exploitable: Optional[bool] = Query(None, description="Nur exploitable CVEs"),
@@ -649,7 +656,7 @@ async def list_vulnerabilities(
# `wazuh_only` would be too brittle, use string contains.
if source:
src = source.strip().lower()
if src in {"wazuh", "nessus", "manual", "app-scan", "defender", "intune"}:
if src in {"wazuh", "nessus", "manual", "app-scan", "defender", "intune", "trivy", "grype", "dockhand"}:
query = query.filter(Vulnerability.sources.contains(f'"{src}"'))
if finding_type == "mobile":
@@ -801,6 +808,7 @@ async def list_vulnerabilities(
vulnerabilities = query.offset(offset).limit(limit).all()
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, items_resp)
_attach_runs_on(db, vulnerabilities, items_resp)
return {"items": items_resp, "total": total_count}
if sort_by == "cpr":
@@ -821,6 +829,7 @@ async def list_vulnerabilities(
vulnerabilities = query.offset(offset).limit(limit).all()
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, items_resp)
_attach_runs_on(db, vulnerabilities, items_resp)
return {"items": items_resp, "total": total_count}
if sort_by == "published_date":
@@ -865,6 +874,7 @@ async def list_vulnerabilities(
vulnerabilities = query.offset(offset).limit(limit).all()
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, items_resp)
_attach_runs_on(db, vulnerabilities, items_resp)
return {"items": items_resp, "total": total_count}
if sort_by == "cve_id":
@@ -899,6 +909,7 @@ async def list_vulnerabilities(
vulnerabilities = query.offset(offset).limit(limit).all()
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, items_resp)
_attach_runs_on(db, vulnerabilities, items_resp)
return {"items": items_resp, "total": total_count}
sort_column = sort_map.get(sort_by, Vulnerability.cvss_score)
@@ -914,9 +925,29 @@ async def list_vulnerabilities(
vulnerabilities = query.offset(offset).limit(limit).all()
results = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, results)
_attach_runs_on(db, vulnerabilities, results)
return {"items": results, "total": total_count}
def _attach_runs_on(db: Session, vulns, results: list[dict]) -> None:
"""Name the Docker host(s) of a container-image finding. Dockhand findings
sit on the image asset, so the Host column showed the image only; the
Dockhand sync links the image to its host asset (container_placements).
One query for the whole page. No link (host name matched no asset, or
several) empty, the image name stays the only trace."""
runs_on: dict[int, list[dict]] = {}
ids = {v.asset_id for v in vulns}
if ids:
for image_id, host_id, host_name in (
db.query(ContainerPlacement.image_asset_id, Asset.id, Asset.hostname)
.join(Asset, Asset.id == ContainerPlacement.host_asset_id)
.filter(ContainerPlacement.image_asset_id.in_(ids))
.order_by(func.lower(Asset.hostname))):
runs_on.setdefault(image_id, []).append({"id": host_id, "hostname": host_name})
for r in results:
r["runs_on"] = runs_on.get(r["asset_id"], [])
def _attach_last_change(db: Session, vulns, results: list[dict]) -> None:
"""Bulk-load latest VULNERABILITY_UPDATED audit row per vuln and
glue a `last_change` dict onto each response item.
@@ -983,6 +1014,51 @@ def _attach_last_change(db: Session, vulns, results: list[dict]) -> None:
logger.warning("last_change preload failed: %s", e)
@router.get("/patch-plan")
def get_patch_plan(
limit: Optional[int] = Query(None, ge=1, description="Top N groups; default: the whole plan"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Open findings grouped by the product to update, exploited first.
The whole plan by default the GUI paginates it client-side, so a cap
here would silently hide work rather than page through it.
"""
from app.services.patch_plan_service import build_patch_plan
return build_patch_plan(db, limit=limit)
@router.get("/patch-plan/export")
def export_patch_plan_csv(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""The whole patch plan as CSV, in rank order. Lists are joined with " | "."""
from app.services.patch_plan_service import build_patch_plan
buf = io.StringIO()
writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL)
writer.writerow([
"Rank", "Update", "Fixed In", "Hosts", "Findings", "CVEs",
"Exploited CVE Count", "Exploited CVEs", "Priority Sum", "Priority Max",
"Check Config CVE Count", "Check Config CVEs",
])
for rank, g in enumerate(build_patch_plan(db), 1):
writer.writerow([
rank, g["product"], " | ".join(g["fixed_versions"]),
g["hosts"], g["findings"], g["cve_count"],
len(g["exploited_cves"]), " | ".join(g["exploited_cves"]),
g["priority_sum"], g["priority_max"],
len(g["config_dependent_cves"]), " | ".join(g["config_dependent_cves"]),
])
fname = f"patch_plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@router.get("/ai-prioritization")
def get_ai_prioritization(
limit: int = Query(20, le=50),
@@ -1022,7 +1098,7 @@ def get_ai_prioritization(
@router.get("/ai-history")
async def get_ai_history(
def get_ai_history(
limit: int = 10,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
@@ -1050,7 +1126,7 @@ async def get_ai_history(
@router.get("/reports/dashboard", response_model=DashboardStatsResponse)
async def get_dashboard_statistics(
def get_dashboard_statistics(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
@@ -1155,7 +1231,7 @@ async def get_dashboard_statistics(
@router.get("/{vuln_id}", response_model=VulnerabilityDetailResponse)
async def get_vulnerability_detail(
def get_vulnerability_detail(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
@@ -1206,7 +1282,7 @@ async def get_vulnerability_detail(
@router.patch("/{vuln_id}", response_model=VulnerabilityResponse)
async def update_vulnerability_status(
def update_vulnerability_status(
vuln_id: int,
update_data: VulnerabilityUpdateRequest,
background_tasks: BackgroundTasks,
@@ -1587,7 +1663,7 @@ def _run_date_backfill_threaded() -> dict:
@router.post("/dates/backfill", status_code=202)
async def backfill_cve_dates(
def backfill_cve_dates(
current_user: User = Depends(RequireEditor),
):
"""Kick off an on-demand CVE published/last-modified backfill.
@@ -1626,7 +1702,7 @@ def refresh_kev_catalog(
@router.patch("/{vuln_id}/assign")
async def assign_vulnerability(
def assign_vulnerability(
vuln_id: int,
assign_data: AssignRequest,
db: Session = Depends(get_db),
@@ -1656,7 +1732,7 @@ async def assign_vulnerability(
@router.patch("/{vuln_id}/defer")
async def defer_vulnerability(
def defer_vulnerability(
vuln_id: int,
defer_data: DeferRequest,
db: Session = Depends(get_db),
@@ -1680,7 +1756,7 @@ async def defer_vulnerability(
@router.patch("/{vuln_id}/undefer")
async def undefer_vulnerability(
def undefer_vulnerability(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -1703,7 +1779,7 @@ async def undefer_vulnerability(
@router.patch("/{vuln_id}/suppress-notifications")
async def toggle_notification_suppression(
def toggle_notification_suppression(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -1724,7 +1800,7 @@ class FalsePositiveRequest(BaseModel):
@router.patch("/{vuln_id}/false-positive")
async def mark_false_positive(
def mark_false_positive(
vuln_id: int,
payload: FalsePositiveRequest,
db: Session = Depends(get_db),
@@ -1752,7 +1828,7 @@ async def mark_false_positive(
@router.patch("/{vuln_id}/unmark-false-positive")
async def unmark_false_positive(
def unmark_false_positive(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
@@ -1775,6 +1851,22 @@ async def unmark_false_positive(
return _build_vuln_response(vuln)
@router.post("/sync/container")
def sync_container_image_report(
report: dict = Body(..., description="Trivy `--format json` or Grype `-o json` output of ONE image"),
min_severity: str = Query("high", description="none|low|medium|high|critical — below is not imported"),
only_fixed: bool = Query(True, description="Import only CVEs with a published fix"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Import one container image scan. See container_scan_service."""
from app.services.container_scan_service import ingest_report
try:
return ingest_report(db, report, min_severity=min_severity, only_fixed=only_fixed)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
@router.post("/sync/wazuh")
def sync_vulnerabilities_from_wazuh(
db: Session = Depends(get_db),
@@ -1788,12 +1880,35 @@ def sync_vulnerabilities_from_wazuh(
try:
result = run_wazuh_vulnerability_sync(db)
return result
except HTTPException:
raise # already carries the real reason and status
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def run_wazuh_vulnerability_sync(db: Session) -> dict:
"""Synchronous Wazuh vulnerability sync with statistics return"""
"""Synchronous Wazuh vulnerability sync with statistics return.
Recorded in the sync-run ledger as the "vulnerabilities" phase."""
from app.services.sync_run_service import record_sync_run, fail_if_source_broken
with record_sync_run("wazuh") as run:
run.stats.setdefault("phase", "vulnerabilities")
result = _run_wazuh_vulnerability_sync(db)
run.stats.update({k: v for k, v in result.items() if k != "message"})
# Only the INDEXER password was wrong once (2026-09-06): the manager
# API answered, so nothing raised, every agent came back with 0 CVEs
# and the run was COMPLETED — green health, green dialog, no mail.
# A 401/5xx/connection error among the agents is the source being
# down, and so is a run in which no agent returned anything.
fail_if_source_broken(run, result.get("agents_synced", 0), result.get("errors", []))
if run.error is None and result.get("outage"):
run.fail(f"Wazuh {result['outage']}")
if run.error:
# The button must go red instead of "Synchronization Complete".
raise HTTPException(status_code=502, detail=run.error)
return result
def _run_wazuh_vulnerability_sync(db: Session) -> dict:
# 1. Get Wazuh Config from DB (transparently decrypted)
from app.auth.setting_crypto import read_setting_value
@@ -1836,6 +1951,9 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
# the pair that decides whether an empty answer is a clean host or an
# unreachable API. Judged after the loop, not inside it.
run_stats: dict = {}
# Per-agent failures, handed to fail_if_source_broken() by the caller: a
# 401 from the indexer is the source being down, not a host with no CVEs.
errors: list[str] = []
# IDs of vulns created during this sync — handed to the digest dispatcher
# after the full sync completes (one summary email per recipient instead
# of one email per CVE).
@@ -1881,8 +1999,21 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
)
db.add(scan_row)
# Get vulns from Wazuh
wazuh_vulns = wazuh.get_vulnerabilities(agent_id)
# Get vulns from Wazuh. An agent the indexer refused is neither
# synced nor empty: it must not reach the backfill below, and it
# must not be handed to reconcile_empty_agents() — that would
# close every Wazuh finding on the host as soon as ONE other
# agent answered, on the strength of a 401.
try:
wazuh_vulns = wazuh.get_vulnerabilities(agent_id)
except Exception as e:
logger.error("Wazuh sync: agent %s (%s) could not be read: %s",
agent_id, asset.hostname, e)
errors.append(f"{asset.hostname}: {e}")
scan_row.status = ScanStatus.FAILED
scan_row.completed_at = datetime.now()
scan_row.error_message = str(e)[:500]
continue
stats["total_vulns_from_wazuh"] += len(wazuh_vulns)
# 1. Deduplicate Wazuh vulns by CVE ID for this asset
@@ -2170,17 +2301,22 @@ def run_wazuh_vulnerability_sync(db: Session) -> dict:
f"Confirmed: {stats.get('vulns_confirmed', 0)}, "
f"Patched: {stats['vulns_patched']}"
),
**stats
**stats,
"errors": errors,
# Only present when reconcile_empty_agents() saw the outage
# pattern: every agent answered without a single CVE. The caller
# turns both this and `errors` into a failed run.
**({"outage": run_stats["outage"]} if run_stats.get("outage") else {}),
}
except Exception as e:
db.rollback()
wazuh.close()
logger.error(f"Wazuh sync failed: {e}")
raise HTTPException(status_code=500, detail="Wazuh sync failed. Check server logs for details.")
raise HTTPException(status_code=502, detail=f"Wazuh sync failed: {e}")
async def sync_wazuh_vulnerabilities(db_session: Optional[Session] = None):
def sync_wazuh_vulnerabilities(db_session: Optional[Session] = None):
"""Background-Task for Wazuh-Sync (for Scheduler)"""
from app.database import SessionLocal
@@ -2208,6 +2344,11 @@ def reconcile_empty_agents(db: Session, run_stats: dict) -> int:
if not empty:
return 0
if not run_stats.get("cves_seen"):
# Nothing was read and nothing was closed — that is a failed run, not
# a quiet warning in the container log. The caller reads `outage` and
# fails the run with it (see run_wazuh_vulnerability_sync).
run_stats["outage"] = (f"every agent returned 0 CVEs ({len(empty)} agents) — "
f"nothing was synced and nothing was closed")
logger.warning("Wazuh sync: every agent returned 0 CVEs — treating as an "
"outage, not closing anything (%d agents affected)", len(empty))
return 0
@@ -2593,7 +2734,7 @@ class OverrideStatsResponse(BaseModel):
@router.get("/override/check")
async def check_incorrect_scores(
def check_incorrect_scores(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
@@ -2773,7 +2914,7 @@ def override_from_vulnrichment(
@router.post("/override/vulnrichment/start")
async def start_vulnrichment_correction_job(
def start_vulnrichment_correction_job(
cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs (leer = alle)"),
dry_run: bool = Query(False, description="Nur simulieren ohne DB-Änderungen"),
@@ -2804,7 +2945,7 @@ async def start_vulnrichment_correction_job(
@router.get("/override/vulnrichment/status/{job_id}")
async def get_vulnrichment_correction_status(
def get_vulnrichment_correction_status(
job_id: str,
current_user: User = Depends(get_current_user),
):
@@ -2828,7 +2969,7 @@ async def get_vulnrichment_correction_status(
@router.get("/override/vulnrichment/jobs")
async def list_vulnrichment_correction_jobs(
def list_vulnrichment_correction_jobs(
limit: int = Query(10, le=50),
current_user: User = Depends(get_current_user),
):
@@ -3197,6 +3338,81 @@ def run_app_cve_scan_endpoint(
raise HTTPException(502, f"App CVE scan failed: {e}")
# Background MSRC scan job — the sync endpoint below stays for API clients,
# but the GUI cannot use it: with rebuild_index the run re-pulls the monthly
# CVRF documents first, which outlives the browser's HTTP patience exactly the
# way the app scan did ("Backend connection failed" while the backend finished).
_MSRC_SCAN_JOB: dict = {"running": False, "stage": None, "result": None,
"error": None, "finished_at": None}
def _msrc_scan_job_runner(asset_id: Optional[int], rebuild_index: bool):
"""Run the MSRC fixed-build scan on its own DB session."""
import time as _time
from app.database import SessionLocal
from app.services import msrc_scan_service
db = SessionLocal()
try:
if rebuild_index:
_MSRC_SCAN_JOB["stage"] = "rebuilding the MSRC index…"
msrc_scan_service.build_product_index(db)
_MSRC_SCAN_JOB["stage"] = "scanning…"
stats = msrc_scan_service.run_msrc_scan(db, asset_id=asset_id)
_MSRC_SCAN_JOB["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
_MSRC_SCAN_JOB["error"] = None
_MSRC_SCAN_JOB["stage"] = "completed"
except Exception as e:
logger.exception("MSRC scan background job failed")
_MSRC_SCAN_JOB["result"] = None
_MSRC_SCAN_JOB["error"] = str(e)
_MSRC_SCAN_JOB["stage"] = "failed"
finally:
db.close()
_MSRC_SCAN_JOB["running"] = False
_MSRC_SCAN_JOB["finished_at"] = _time.time()
@router.post("/msrc-scan/start", status_code=202)
def start_msrc_scan_job(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all Windows Server assets"),
rebuild_index: bool = Query(True, description="Re-pull the MSRC CVRF documents first"),
current_user: User = Depends(RequireEditor),
):
"""Start the MSRC fixed-build scan in the background; poll /msrc-scan/status.
`rebuild_index` defaults to True here (and to False on the sync endpoint):
this is the button an operator presses after Microsoft published something,
and the answer they want is what MSRC says NOW. The index it rebuilds is
the same one the app scan's package pass reads, so the Edge/SharePoint
findings of the next app scan come from this build too.
"""
import threading
if _MSRC_SCAN_JOB["running"]:
return {"started": False, "message": "An MSRC scan is already running",
"poll_url": "/api/v1/vulnerabilities/msrc-scan/status"}
_MSRC_SCAN_JOB.update({"running": True, "stage": "starting", "result": None,
"error": None, "finished_at": None})
threading.Thread(target=_msrc_scan_job_runner, args=(asset_id, rebuild_index),
daemon=True).start()
return {"started": True, "poll_url": "/api/v1/vulnerabilities/msrc-scan/status"}
@router.get("/msrc-scan/status")
def get_msrc_scan_status(current_user: User = Depends(get_current_user)):
"""Snapshot of the background MSRC scan (running / result)."""
s = _MSRC_SCAN_JOB
return {
"running": s["running"],
"stage": s["stage"],
"result": s["result"],
"error": s["error"],
"finished_at": s["finished_at"],
"status": ("running" if s["running"]
else "failed" if s["error"]
else "completed" if s["result"] is not None else "idle"),
}
@router.post("/msrc-scan")
# Sync def → worker threadpool; the CVRF fetch is blocking I/O.
def run_msrc_scan_endpoint(
@@ -3242,7 +3458,7 @@ def suppress_false_positives_endpoint(
@router.get("/ai-remediation/status")
async def ai_remediation_status(
def ai_remediation_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -3490,7 +3706,7 @@ def _run_msrc_refresh_threaded(months_back: Optional[int] = None) -> None:
@router.post("/msrc/refresh", status_code=202)
async def refresh_msrc_endpoint(
def refresh_msrc_endpoint(
months_back: Optional[int] = Query(None, description="How many recent monthly MSRC docs to ingest"),
current_user: User = Depends(RequireEditor),
):
@@ -3509,7 +3725,7 @@ async def refresh_msrc_endpoint(
@router.get("/msrc/refresh/status")
async def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
"""Poll target for the GUI: current/last MSRC-refresh state + stats."""
s = _MSRC_REFRESH
state = ("running" if s["running"] else "error" if s["error"]
@@ -3542,7 +3758,7 @@ def recompute_priority_scores(
@router.post("/canonicalize-cve-metadata")
async def canonicalize_cve_metadata(
def canonicalize_cve_metadata(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
@@ -3604,7 +3820,7 @@ async def canonicalize_cve_metadata(
@router.get("/override/stats")
async def get_override_stats(
def get_override_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
+338 -117
View File
@@ -29,6 +29,10 @@ from app.models.policy import Policy
from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus
from app.models.user import User
from app.integrations.wazuh_client import WazuhClient
from app.services.sync_run_service import (
record_sync_run, mark_interrupted_runs, fail_if_source_broken,
probe_wazuh_api, report_source_failure, source_failure,
)
scheduler = AsyncIOScheduler() if HAS_APSCHEDULER else None
@@ -59,7 +63,9 @@ def execute_scheduled_scan(schedule_id: int):
if scanner == "nessus":
try:
from app.services.nessus_sync import run_nessus_sync
stats = run_nessus_sync(db)
with record_sync_run("nessus", "scheduled") as run:
stats = run_nessus_sync(db)
run.stats.update(stats)
logger.info(f"Scheduled Nessus sync done: {stats}")
except Exception as e:
logger.error(f"Scheduled Nessus sync failed: {e}")
@@ -85,97 +91,120 @@ def execute_scheduled_scan(schedule_id: int):
db.commit()
return
# ---- Wazuh branch (default, existing behaviour) ----
# Wazuh Config laden (transparently decrypted)
from app.auth.setting_crypto import read_setting_value
raw_wazuh = read_setting_value(db, "wazuh_config")
if not raw_wazuh:
logger.error("Scheduled scan aborted: Wazuh configuration missing")
return
with record_sync_run("wazuh", "scheduled") as run:
# ---- Wazuh branch (default, existing behaviour) ----
# Wazuh Config laden (transparently decrypted)
from app.auth.setting_crypto import read_setting_value
raw_wazuh = read_setting_value(db, "wazuh_config")
if not raw_wazuh:
logger.error("Scheduled scan aborted: Wazuh configuration missing")
run.fail("Wazuh configuration missing")
return
config = json.loads(raw_wazuh)
config = json.loads(raw_wazuh)
# Asset sync first — the same call the manual "Sync Data (Wazuh)"
# button makes before it syncs vulnerabilities. Without it this job
# only ever sees the agent ids already in the DB: a host that
# re-registers with a NEW agent id keeps its stale id forever, so
# every CVE and EOL lookup asks Wazuh about an agent that no longer
# exists, and the asset never flips back from INACTIVE to ACTIVE
# until someone clicks sync by hand.
# ponytail: the endpoint function is called directly (current_user is
# unused in its body) rather than extracted into a service — same
# pattern as the sync_agent_vulnerabilities import below.
try:
from app.routers.assets import sync_wazuh_assets
logger.info(f"Scheduled Wazuh asset sync: {sync_wazuh_assets(db=db, current_user=None)}")
except Exception as e:
logger.error(f"Scheduled Wazuh asset sync failed: {e}")
# Asset sync first — the same call the manual "Sync Data (Wazuh)"
# button makes before it syncs vulnerabilities. Without it this job
# only ever sees the agent ids already in the DB: a host that
# re-registers with a NEW agent id keeps its stale id forever, so
# every CVE and EOL lookup asks Wazuh about an agent that no longer
# exists, and the asset never flips back from INACTIVE to ACTIVE
# until someone clicks sync by hand.
# ponytail: the endpoint function is called directly (current_user is
# unused in its body) rather than extracted into a service — same
# pattern as the sync_agent_vulnerabilities import below.
#
# The asset sync logs in to the manager API (55000); the agent
# loop below reads the indexer (9200). One can be dead while the
# other answers — the API refused every login for a day while 64
# agents "synced" fine each hour, and the run stayed COMPLETED
# with the error folded into a list. A phase that could not log
# in is a failed run, whatever the other phase did.
phase_failures = []
try:
from app.routers.assets import sync_wazuh_assets
logger.info(f"Scheduled Wazuh asset sync: {sync_wazuh_assets(db=db, current_user=None)}")
except Exception as e:
detail = getattr(e, "detail", None) or str(e)
logger.error(f"Scheduled Wazuh asset sync failed: {detail}")
run.stats.setdefault("errors", []).append(f"asset sync: {detail}")
phase_failures.append(f"asset sync failed: {detail}")
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
if not assets:
logger.info("No Wazuh assets found for scheduled scan")
schedule.last_run = datetime.now()
db.commit()
return
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
if not assets:
logger.info("No Wazuh assets found for scheduled scan")
schedule.last_run = datetime.now()
db.commit()
fail_if_source_broken(run, 0, [], phase_failures)
return
triggered = 0
errors = []
triggered = 0
errors = []
try:
with WazuhClient(
base_url=config.get("api_url"),
username=config.get("username"),
password=config.get("password"),
indexer_url=config.get("indexer_url"),
indexer_username=config.get("indexer_username"),
indexer_password=config.get("indexer_password"),
verify_ssl=bool(config.get("verify_ssl", True))
) as client:
# Sync vulnerabilities for each agent
from app.routers.vulnerabilities import (
sync_agent_vulnerabilities, reconcile_empty_agents,
)
# Shared across the run so an agent that returns nothing can be
# judged against the run as a whole (see reconcile_empty_agents).
run_stats: dict = {}
for asset in assets:
try:
with WazuhClient(
base_url=config.get("api_url"),
username=config.get("username"),
password=config.get("password"),
indexer_url=config.get("indexer_url"),
indexer_username=config.get("indexer_username"),
indexer_password=config.get("indexer_password"),
verify_ssl=bool(config.get("verify_ssl", True))
) as client:
# Sync vulnerabilities for each agent
from app.routers.vulnerabilities import (
sync_agent_vulnerabilities, reconcile_empty_agents,
)
# Shared across the run so an agent that returns nothing can be
# judged against the run as a whole (see reconcile_empty_agents).
run_stats: dict = {}
for asset in assets:
try:
scan = Scan(
asset_id=asset.id,
scan_type=ScanType.WAZUH, # source-labelled (was FULL)
status=ScanStatus.RUNNING,
started_at=datetime.now()
)
db.add(scan)
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
asset, run_stats=run_stats)
scan.status = ScanStatus.COMPLETED
scan.completed_at = datetime.now()
triggered += 1
except Exception as e:
scan.status = ScanStatus.FAILED
scan.error_message = str(e)
errors.append(f"{asset.hostname}: {e}")
logger.error(f"Scheduled scan error for {asset.hostname}: {e}")
# Now that the run is done, agents that returned nothing can be
# judged: real emptiness if the API answered for anyone else.
try:
scan = Scan(
asset_id=asset.id,
scan_type=ScanType.WAZUH, # source-labelled (was FULL)
status=ScanStatus.RUNNING,
started_at=datetime.now()
)
db.add(scan)
sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
asset, run_stats=run_stats)
scan.status = ScanStatus.COMPLETED
scan.completed_at = datetime.now()
triggered += 1
reconcile_empty_agents(db, run_stats)
except Exception as e:
scan.status = ScanStatus.FAILED
scan.error_message = str(e)
errors.append(f"{asset.hostname}: {e}")
logger.error(f"Scheduled scan error for {asset.hostname}: {e}")
logger.error(f"Wazuh empty-agent reconcile failed: {e}")
# Every agent answered without a single CVE: the indexer
# is down or empty, whatever the manager API said.
if run_stats.get("outage"):
phase_failures.append(f"vulnerability sync: {run_stats['outage']}")
# Now that the run is done, agents that returned nothing can be
# judged: real emptiness if the API answered for anyone else.
try:
reconcile_empty_agents(db, run_stats)
except Exception as e:
logger.error(f"Wazuh empty-agent reconcile failed: {e}")
except Exception as e:
logger.error(f"Wazuh connection error during scheduled scan: {e}")
run.fail(f"Wazuh connection failed: {e}")
except Exception as e:
logger.error(f"Wazuh connection error during scheduled scan: {e}")
schedule.last_run = datetime.now()
interval_delta = INTERVAL_MAP.get(schedule.interval, {"days": 1})
schedule.next_run = datetime.now() + timedelta(**interval_delta)
db.commit()
schedule.last_run = datetime.now()
interval_delta = INTERVAL_MAP.get(schedule.interval, {"days": 1})
schedule.next_run = datetime.now() + timedelta(**interval_delta)
db.commit()
logger.info(f"Scheduled scan '{schedule.name}' completed: {triggered} assets scanned, {len(errors)} errors")
logger.info(f"Scheduled scan '{schedule.name}' completed: {triggered} assets scanned, {len(errors)} errors")
run.stats.update({"phase": "assets+vulnerabilities", "agents_synced": triggered,
"errors": run.stats.get("errors", []) + errors})
if run.error is None: # a connection failure above already named the cause
fail_if_source_broken(run, triggered, errors, phase_failures)
except Exception as e:
logger.error(f"Scheduled scan error: {e}")
@@ -483,6 +512,25 @@ def exploit_intel_nightly():
db.close()
def jev_triage_nightly():
"""Jev config hint for CVEs that have none yet (jev_triage_service).
06:00 after every ingest of the night (last one 05:40), so a CVE found
tonight carries its hint when the patch plan is opened in the morning.
No-op unless enabled; a hint only, it changes no finding and no score.
"""
from app.services.jev_triage_service import run_jev_triage
db = SessionLocal()
try:
run_jev_triage(db)
except Exception as e:
logger.error("Jev triage nightly failed: %s", e)
db.rollback()
finally:
db.close()
def eol_check_nightly():
"""Run endoflife.date EOL detection for every Wazuh-linked asset.
@@ -523,9 +571,14 @@ def eol_check_nightly():
indexer_password=cfg.get("indexer_password"),
verify_ssl=bool(cfg.get("verify_ssl", True)),
)
# A refused login is a Wazuh failure, recorded and mailed as one —
# not 64 "package fetch failed" warnings and a job that "completed".
if probe_wazuh_api(wazuh, phase="eol-check", trigger="scheduled"):
return
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
upserts = 0
closed = 0
fetch_errors = []
for asset in assets:
# Every EOL finding this asset still justifies; what is missing at
# the end of the pass has left the inventory. Same contract as the
@@ -554,6 +607,7 @@ def eol_check_nightly():
pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
except Exception as e:
logger.warning("EOL: package fetch failed for %s: %s", asset.hostname, e)
fetch_errors.append(f"{asset.hostname}: {e}")
continue
# Same sweep the button runs — one implementation, so the nightly
# result and the on-demand result cannot drift apart.
@@ -563,7 +617,12 @@ def eol_check_nightly():
closed += res["closed"]
db.commit()
logger.info("EOL nightly: %d assets scanned, %d EOL upserts, %d closed "
"(no longer installed)", len(assets), upserts, closed)
"(no longer installed), %d fetch errors",
len(assets), upserts, closed, len(fetch_errors))
# The API dying mid-run looks like per-asset noise; it is not.
hit = source_failure(fetch_errors)
if hit:
report_source_failure("wazuh", "scheduled", "eol-check packages", hit)
except Exception as e:
logger.error("EOL nightly failed: %s", e)
db.rollback()
@@ -592,7 +651,9 @@ def intune_sync_nightly():
m365_service.fetch_security_data(db, force_refresh=True)
except Exception as e:
logger.warning("M365 page refresh failed (non-fatal, cache kept): %s", e)
stats = run_intune_sync(db)
with record_sync_run("intune", "scheduled") as run:
stats = run_intune_sync(db)
run.stats.update(stats)
logger.info("Intune nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("Intune nightly failed: %s", e)
@@ -618,7 +679,9 @@ def vcenter_sync_nightly():
if not load_vcenter_config(db):
logger.info("vCenter sync skipped — vcenter_config not set")
return
stats = run_vcenter_sync(db, refresh_catalog=True)
with record_sync_run("vcenter", "scheduled") as run:
stats = run_vcenter_sync(db, refresh_catalog=True)
run.stats.update(stats)
logger.info("vCenter nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("vCenter nightly failed: %s", e)
@@ -640,7 +703,9 @@ def igel_sync_nightly():
if not load_igel_config(db):
logger.info("IGEL sync skipped — igel_config not set")
return
stats = run_igel_sync(db)
with record_sync_run("igel", "scheduled") as run:
stats = run_igel_sync(db)
run.stats.update(stats)
logger.info("IGEL nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("IGEL nightly failed: %s", e)
@@ -649,6 +714,56 @@ def igel_sync_nightly():
db.close()
def netdisco_sync_nightly():
"""Sync Netdisco's device inventory → assets and firmware CVEs.
Skipped when netdisco_config is not set. Slotted at 02:40 UTC, right after
the IGEL sync and before the app-CVE scan at 03:20 the inventory has to
exist before anything scans it, and the cvelistV5 index it reads was
rebuilt at 01:30 (vuln_index_refresh_nightly).
"""
from app.services.netdisco_service import load_netdisco_config, run_netdisco_sync
db = SessionLocal()
try:
if not load_netdisco_config(db):
logger.info("Netdisco sync skipped — netdisco_config not set")
return
with record_sync_run("netdisco", "scheduled") as run:
stats = run_netdisco_sync(db)
run.stats.update(stats)
logger.info("Netdisco nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("Netdisco nightly failed: %s", e)
db.rollback()
finally:
db.close()
def dockhand_sync_nightly():
"""Pull Dockhand's cached container-image scan findings.
Skipped when dockhand_config is not set. 02:50 UTC, after the other
inventory syncs. Reads nothing another job refreshes with `scan_nightly`
it has Dockhand rescan every image first (Dockhand has no scan schedule),
and enrichment runs inline for new findings.
"""
from app.services.dockhand_service import load_dockhand_config, run_dockhand_sync
db = SessionLocal()
try:
if not load_dockhand_config(db):
logger.info("Dockhand sync skipped — dockhand_config not set")
return
with record_sync_run("dockhand", "scheduled") as run:
stats = run_dockhand_sync(db, rescan=True)
run.stats.update(stats)
logger.info("Dockhand nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("Dockhand nightly failed: %s", e)
db.rollback()
finally:
db.close()
def m365_check_nightly():
"""Detect Microsoft 365 Apps CVEs (Plan P) for every Wazuh-linked asset.
@@ -711,6 +826,89 @@ def m365_check_nightly():
db.close()
def vuln_index_refresh_nightly():
"""Rebuild every vulnerability index a nightly sync decides from — BEFORE
the first sync of the night, not inside the last scan.
01:30 UTC. The Intune sync at 02:10 runs the app-CVE scan for each of its
devices (and the Defender TVM pull), the IGEL sync at 02:30 scans its
endpoints and the UMS server, the vCenter sync at 02:20 its hosts, the
Netdisco sync at 02:40 its switches and
every one of them reads the cvelistV5 index and the vendor indexes as
stored. Those used to be rebuilt by the app-CVE job at 03:20, i.e. AFTER
all three syncs, so an Intune-only device was matched against yesterday's
catalogue every night (the 03:20 pass over all assets caught up, but only
once it got there hours later on a large estate). Now:
01:30 this job cvelistV5 (+MFSA), GitHub advisories,
TeamViewer bulletins, IGEL ISNs, MSRC fixed builds
02:10 Intune/Defender sync reads them fresh
02:20 vCenter sync
02:30 IGEL sync
02:40 Netdisco sync
03:20 app-CVE scan reads the same index; builds only if missing
03:50 MSRC OS scan reuses the MSRC index from 01:30
Each rebuild is independent and a failed one keeps the cached index (see
the build functions), so an offline vendor site costs that source a night,
never the others and never the syncs.
ponytail: the 40-minute gap is the guarantee. The elapsed time is logged;
if this job ever runs past 02:10, chain the Intune sync onto it instead of
widening the gap.
"""
import time as _time
from app.services import (cvelistv5_scan_service, github_repo_advisory_service,
igel_isn_service, teamviewer_bulletin_service)
db = SessionLocal()
t0 = _time.monotonic()
try:
# force_fresh: the /tmp ZIP is shared with the threat-intel refresh,
# which runs on an interval anchored to app startup — under the plain
# 12h TTL this build kept reusing an afternoon snapshot and missed the
# evening's CVEs for a full extra night (Chrome 151.0.7922.169).
try:
cvelistv5_scan_service.build_product_index(db, force_fresh=True)
except Exception as e:
logger.warning("cvelistV5 index build failed (non-fatal, cache kept): %s", e)
# Vendor indexes: all cache 24h and used to be refreshed by whoever
# asked first — the scan itself, one build-duration short of the TTL
# every night, so they only ever cleared it every OTHER night (the
# MFSA bug, 978d4f3). Notepad++, Wazuh and the IGEL ISNs without a
# CVE reach no other source at all; TeamViewer publishes days before
# NVD — a night late is a night blind.
from app.services import citrix_workspace_service
for _mod, _label in ((github_repo_advisory_service, "repo-advisory"),
(teamviewer_bulletin_service, "teamviewer-bulletin"),
(citrix_workspace_service, "citrix-build-catalog"),
(igel_isn_service, "igel-isn")):
try:
_mod.build_index(db)
except Exception as e:
logger.warning("%s index build failed (non-fatal, cache kept): %s",
_label, e)
# MSRC fixed builds — the one index b802f3a left where it was. The
# 03:20 app scan's package pass creates the Edge findings from it, and
# it was rebuilt by the MSRC job at 03:50, i.e. AFTER that scan, so a
# Chromium CVE MSRC filed under Edge on day D reached the host on D+2
# (CVE-2026-84324: published 03.09. 17:00, "app-scan only" on 04.09.).
# Edge CVEs exist in no other source, so a night late is a night blind.
# The 03:50 job reuses this build (ensure_index) instead of pulling
# the 18 documents again; a failed build keeps the cached index.
try:
from app.services import msrc_scan_service
msrc_scan_service.build_product_index(db)
except Exception as e:
logger.warning("MSRC index build failed (non-fatal, cache kept): %s", e)
logger.info("Vulnerability index refresh done in %.0fs — the 02:10 "
"Intune/Defender sync reads these", _time.monotonic() - t0)
except Exception as e:
logger.error("Vulnerability index refresh failed: %s", e)
db.rollback()
finally:
db.close()
def app_cve_scan_nightly():
"""Built-in app→CVE scanner for every asset with software inventory.
@@ -719,42 +917,19 @@ def app_cve_scan_nightly():
closes the coverage gap for Intune-only / mobile devices that have no
real scanner. Source 'app-scan'; cross-confirms with the other scanners.
Slotted at 03:25 UTC after M365 (03:20), before the audit prune (03:30).
Slotted at 03:20 UTC. The cvelistV5 and vendor indexes it decides from
are rebuilt at 01:30 by vuln_index_refresh_nightly before the Intune,
vCenter, IGEL and Netdisco syncs that read them too; run_app_cve_scan builds the
cvelistV5 index itself only when none is stored yet.
Cache (TTL 7d) keeps OSV/NVD load bounded; NVD_API_KEY recommended.
"""
from app.services import (app_cve_scanner_service, cvelistv5_scan_service,
github_repo_advisory_service,
teamviewer_bulletin_service)
from app.services import app_cve_scanner_service
db = SessionLocal()
try:
# Rebuild the cvelistV5 reverse index first (one ~557 MB ZIP walk) so
# the scan below has fresh product→CVE ranges for curated software.
# force_fresh: the /tmp ZIP is shared with the threat-intel refresh,
# which runs on an interval anchored to app startup — under the plain
# 12h TTL this build kept reusing an afternoon snapshot and missed the
# evening's CVEs for a full extra night (Chrome 151.0.7922.169).
try:
cvelistv5_scan_service.build_product_index(db, force_fresh=True)
except Exception as e:
logger.warning("cvelistV5 index build failed (non-fatal): %s", e)
# Same for the two vendor indexes the scan below decides from. Both
# cache 24h and used to be refreshed by whoever asked first — which is
# the scan itself, one build-duration short of the TTL every night, so
# they only ever cleared it every OTHER night (the MFSA bug, 978d4f3).
# Notepad++ and Wazuh advisories reach no other source at all, and
# TeamViewer publishes days before NVD, so a night late is a night
# blind. A failed rebuild keeps the cached index (see build_index).
for _mod, _label in ((github_repo_advisory_service, "repo-advisory"),
(teamviewer_bulletin_service, "teamviewer-bulletin")):
try:
_mod.build_index(db)
except Exception as e:
logger.warning("%s index build failed (non-fatal, cache kept): %s",
_label, e)
# FP-suppression is part of run_app_cve_scan itself now, so every way of
# starting a scan — nightly, GUI, single asset — produces the same
# result. It used to hang off this job alone.
stats = app_cve_scanner_service.run_app_cve_scan(db)
stats = app_cve_scanner_service.run_app_cve_scan(db, trigger="scheduled")
logger.info("App CVE scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
except Exception as e:
logger.error("App CVE scan nightly failed: %s", e)
@@ -792,7 +967,10 @@ def msrc_scan_nightly():
from app.services import msrc_scan_service, msrc_service
db = SessionLocal()
try:
msrc_scan_service.build_product_index(db)
# The index was rebuilt at 01:30 by vuln_index_refresh_nightly, before
# the app scan that creates the Edge findings from it. Reuse that
# build; rebuild only when it did not happen (or is a day old).
msrc_scan_service.ensure_index(db, max_age=msrc_scan_service.SCAN_INDEX_MAX_AGE)
stats = msrc_scan_service.run_msrc_scan(db)
logger.info("MSRC scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
# Remediations for what this pass (and the app scan before it) just
@@ -946,7 +1124,7 @@ def refresh_compliance_sca():
db = SessionLocal()
try:
stats = refresh_all_compliance(db)
stats = refresh_all_compliance(db, trigger="scheduled")
logger.info(
"Compliance SCA refresh done: %d assets, %d policy results, %d errors",
stats["assets_synced"], stats["policies_synced"], len(stats["errors"]),
@@ -1268,6 +1446,17 @@ def start_scheduler():
replace_existing=True,
)
# Nightly Jev config hint at 06:00 — after the last ingest (05:40), so
# tonight's new CVEs carry their hint on the patch plan. No-op unless
# enabled; a hint only, changes no finding and no score.
scheduler.add_job(
jev_triage_nightly,
trigger=CronTrigger(hour=6, minute=0),
id="jev_triage_nightly",
name="Nightly Jev Config Hint (off unless enabled)",
replace_existing=True,
)
# Nightly exploit-intel refresh (Plan M) at 03:45 — pulls
# Exploit-DB CSV + PoC-in-GitHub + Metasploit module index, writes
# per-vuln counts + ref lists.
@@ -1280,6 +1469,18 @@ def start_scheduler():
)
# Nightly Microsoft Intune device/inventory sync (02:10 UTC).
# Nightly vulnerability index refresh (01:30 UTC) — cvelistV5 (+MFSA),
# GitHub advisories, TeamViewer bulletins, IGEL ISNs. First job of the
# scan night on purpose: the Intune/Defender sync at 02:10 decides from
# these, and it can run for hours on a large estate.
scheduler.add_job(
vuln_index_refresh_nightly,
trigger=CronTrigger(hour=1, minute=30),
id="vuln_index_refresh_nightly",
name="Nightly Vulnerability Index Refresh",
replace_existing=True,
)
scheduler.add_job(
intune_sync_nightly,
trigger=CronTrigger(hour=2, minute=10),
@@ -1306,6 +1507,24 @@ def start_scheduler():
replace_existing=True,
)
# Nightly Netdisco switch/router inventory sync (02:40 UTC).
scheduler.add_job(
netdisco_sync_nightly,
trigger=CronTrigger(hour=2, minute=40),
id="netdisco_sync_nightly",
name="Nightly Netdisco Device Inventory Sync",
replace_existing=True,
)
# Nightly Dockhand container-image findings pull (02:50 UTC).
scheduler.add_job(
dockhand_sync_nightly,
trigger=CronTrigger(hour=2, minute=50),
id="dockhand_sync_nightly",
name="Nightly Dockhand Container Image Findings Sync",
replace_existing=True,
)
# Nightly built-in app→CVE scan (03:20 UTC) — maps installed software
# (Wazuh packages + Intune detectedApps) to real CVEs via OSV/NVD-CPE;
# closes the coverage gap for Intune-only / mobile devices.
@@ -1349,6 +1568,8 @@ def start_scheduler():
replace_existing=True,
)
# Rows left "running" by a restart mid-sync would show a spinner forever.
mark_interrupted_runs()
scheduler.start()
logger.info(
"Background scheduler started (SLA Breach Checker + Threat Intel Refresh + KEV Alert Hourly + Vulnrichment Nightly + Compliance SCA Nightly + URS Nightly + Audit-Log Prune)"
+4
View File
@@ -183,11 +183,14 @@ def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
source: str = "android-asb", label: str = "ASB") -> None:
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
cve_id = cve.upper()
# Google's rating in the ASB, Samsung's in the SMR.
vendor = "Samsung" if source == "samsung-smr" else "Android"
existing = (db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
.first())
if existing:
existing.add_source(source)
existing.set_vendor_severity(vendor, sev)
if not existing.package_name:
existing.package_name = f"Android ({label} {month[:7]})"[:255]
from app.services.audit_events import reopen_if_patched
@@ -208,6 +211,7 @@ def _upsert(db: Session, asset, month: str, cve: str, sev: str, new_ids: list,
detected_at=datetime.now(),
sources=json.dumps([source]), first_detected_by=source,
)
row.set_vendor_severity(vendor, sev)
db.add(row)
db.flush()
try:
+705 -35
View File
@@ -238,6 +238,16 @@ _REGISTRY: List[tuple] = [
# forever. Only the product entry itself carries the real build.
_cpe(r"^microsoft exchange server subscription edition$",
"microsoft:exchange_server_subscription_edition"),
# Entra Connect Sync, formerly Azure AD Connect — one product, one 1.x/2.x
# build line, and NVD files it under both names (entra_connect since the
# rename, CVE-2026-65673; azure_active_directory_connect before it).
# ANCHORED and exact: the same server carries the Health Agent (4.5.x) and
# the pass-through Authentication Agent / Agent Updater (1.5.x), and 1.5.x
# sits inside the 1.0.0 .. <2.6.84.0 range. The sync-engine row carries the
# product's own build and dedups with it.
_cpe(r"^microsoft (entra|azure ad) connect( sync)?( synchronization services)?$",
"microsoft:entra_connect",
also=["microsoft:azure_active_directory_connect"]),
# Checkmk agent. Wazuh does not detect it (wazuh/wazuh#35646), and its
# inline patch numbering (2.4.0p12) is what kept every path away.
_cpe(r"checkmk agent|check_mk agent|checkmk(?!.*server)", "checkmk:checkmk"),
@@ -302,10 +312,194 @@ _OS_REGISTRY: List[tuple] = [
{"key": "cpe:igel:ums",
"cpe": "cpe:2.3:a:igel:universal_management_suite",
"label": "IGEL Universal Management Suite"}),
# HPE Aruba switches and controllers, inventoried by the Netdisco
# connector. Anchored and ordered CX → Switch → AOS, because "arubaos-cx"
# starts with "arubaos" and a loose pattern would put a campus switch's
# 10.13.1005 next to a Mobility controller's 10.7.2.2.
#
# This is the NVD half of Aruba coverage; the other half — HPE's own CNA
# records — comes from cvelistv5_scan_service.scan_asset_aruba, and neither
# is redundant. NVD carries proper cpeMatch ranges for the three 2023
# ArubaOS-Switch CVEs, which cvelistV5 states as prose and can only partly
# parse; the current AOS-CX batch (CVE-2026-73749, -44880, -73775 ff.) is
# the reverse — clean CNA ranges, and NVD enrichment that arrives days
# later.
(re.compile(r"^(hpe )?(aruba ?)?(arubaos|aos)[- ]?cx\b", re.I),
{"key": "cpe:hpe:arubaos-cx", "cpe": "cpe:2.3:o:hpe:arubaos-cx",
"label": "ArubaOS-CX"}),
(re.compile(r"^(hpe )?(aruba ?)?(arubaos|aos)[- ]?s(witch)?\b", re.I),
{"key": "cpe:hpe:arubaos-switch", "cpe": "cpe:2.3:o:hpe:arubaos-switch",
"label": "ArubaOS-Switch"}),
# The Mobility controllers/gateways. NVD files them under two vendor
# spellings — arubanetworks (250 versions) and the older hp one (32) — for
# the same product, so both are queried and the results merged per CVE.
(re.compile(r"^(hpe )?aruba ?os\b|^arubaos\b|^aos-w\b", re.I),
{"key": "cpe:arubanetworks:arubaos", "cpe": "cpe:2.3:o:arubanetworks:arubaos",
"also": ["cpe:2.3:o:hp:arubaos"], "label": "ArubaOS"}),
# Cisco IOS XE / IOS XR routers and switches, inventoried by the Netdisco
# connector. The asset's OS string is written by netdisco_service as one of
# two canonical labels, never as the "ios-xe" slug SNMP::Info answers with.
#
# These two are the only matching mode in this file that is EXACT and not a
# range, and that is not a simplification — it is what both sources state.
# NVD enumerates one CPE per affected release (267 of them for
# CVE-2026-20267) with no versionStart/End at all, and Cisco's CNA records
# do the same (268 bare `version` entries). So `_in_range` takes its
# exact-version branch for every one of them, which is why that branch had
# to learn about release suffixes: 17.15.1, 17.15.1w, 17.15.1x and 17.15.1y
# are four separate releases sharing one set of numbers.
#
# Classic IOS (the "IOS" product, 12.x/15.x) is deliberately absent: it
# numbers releases as "15.2(7)E3", which NVD escapes into the CPE as
# `15.2\(7\)e3` and no version scheme here reads. Those devices get an
# asset and no CVE verdict, the same as any other unmatched vendor.
#
# This is the NVD half; the cvelistV5 half is
# cvelistv5_scan_service.scan_asset_cisco, and neither is redundant —
# verified 08.09.2026, the IOS XR hardening batch (CVE-2026-20274..20280)
# was still "Awaiting Analysis" at NVD with zero CPE entries while Cisco's
# own records already enumerated all 112 affected releases.
#
# `lettered` is what lets the version through at all: the shared, strict
# _clean_version reads "17.15.4c" as not-a-version, which meant no version,
# which meant no scan. It stays strict for everything else — see
# _lettered_version for why that split is deliberate.
(re.compile(r"^cisco\s+ios[- ]?xe\b", re.I),
{"key": "cpe:cisco:ios_xe", "cpe": "cpe:2.3:o:cisco:ios_xe",
"label": "Cisco IOS XE", "lettered": True}),
(re.compile(r"^cisco\s+ios[- ]?xr\b", re.I),
{"key": "cpe:cisco:ios_xr", "cpe": "cpe:2.3:o:cisco:ios_xr",
"label": "Cisco IOS XR", "lettered": True}),
# Extreme Networks EXOS (marketed as "Switch Engine" since 2022), the third
# firmware line the Netdisco connector inventories. Ordinary ranges and an
# ordinary version — plain dotted numbers, so no `lettered` reader here.
#
# BOTH CPE names are queried, and neither is optional: NVD files the same
# switch firmware under two products and splits the CVEs between them.
# Verified 08.09.2026 — `extremexos` answers CVE-2024-27453, CVE-2020-18305
# and the 2017 batch; `exos` answers CVE-2023-43118..43121, the Chalet
# directory-traversal and privilege-escalation flaws, and NOTHING under the
# other name. A switch on 22.6 gets three findings from each.
#
# This is the NVD half, and for EXOS it is the load-bearing one: Extreme's
# CNA only started filing in 2025, so every earlier EXOS CVE was filed by
# MITRE with vendor "n/a" and carries no structured cvelistV5 data at all.
# The other half (cvelistv5_scan_service.scan_asset_extreme) is what sees
# the current ones — CVE-2026-8169 and -8170 were still "Awaiting Analysis"
# at NVD with zero CPE entries while Extreme's own records already stated
# all four affected ranges.
#
# ExtremeWare XOS (`extremeware_xos`, 10.x/11.x, discontinued 2010) is left
# out: Netdisco reports those as the "extremeware" slug, not "xos", so no
# asset this scanner writes could ever carry a version in that range.
(re.compile(r"^(extreme\s+networks\s+)?"
r"(extremexos|extreme\s+x?os|exos|switch\s+engine)\b", re.I),
{"key": "cpe:extremenetworks:extremexos",
"cpe": "cpe:2.3:o:extremenetworks:extremexos",
"also": ["cpe:2.3:o:extremenetworks:exos"], "label": "ExtremeXOS"}),
# Ubiquiti UniFi access points, the fourth Netdisco firmware line.
#
# NVD files the AP firmware as `ui:unifi_uap_firmware` and pairs it with a
# long list of `h:ui:*` hardware CPEs (U6-LR, UAP-AC-Pro, UBB …). Only the
# `o:` half carries versions, so that is the one queried; the hardware
# entries name which models the record covers and NVD applies them to the
# same records anyway.
#
# `ui:unifi_switch_firmware` is deliberately NOT an `also` here, and the
# reason is the whole point of this entry: a UniFi switch and a UniFi AP
# number their firmware in the SAME scheme (6.5.32 vs 6.5.53 on
# CVE-2023-38034) and Ubiquiti ships them separate fixes. Matched against
# the AP CPE, a USW-24 on 6.5.40 would read as an unpatched AP. The switch
# firmware needs its own key the day a UniFi switch turns up in Netdisco;
# until then netdisco_service.ubiquiti_family only claims AP models.
#
# Verified 09.09.2026 — the CPE carries exactly two CVEs (CVE-2023-35085,
# CVE-2023-38034). The current one, CVE-2024-37380, has been "Deferred" at
# NVD since publication with zero CPE entries, which is what the cvelistV5
# half (cvelistv5_scan_service.scan_asset_ubiquiti) is there for.
(re.compile(r"^(ubiquiti\s+)?unifi\s+ap\b", re.I),
{"key": "cpe:ui:unifi_uap_firmware",
"cpe": "cpe:2.3:o:ui:unifi_uap_firmware", "label": "UniFi AP"}),
# Brocade Fabric OS — the FC SAN switch firmware, filed under Broadcom
# since the acquisition. Fifth Netdisco firmware line, and the ONLY one
# covered from this side alone: Brocade's CNA writes its bounds as English
# ("before 9.2.1c3, and 9.2.2 though 9.2.2b" — the typo is theirs), which
# `_is_version` rejects and no range collector can read, so cvelistV5
# carries no structured data for any of them. NVD's analysts turn the same
# prose into proper cpeMatch ranges, which is why this half is load-bearing
# here. Verified 09.09.2026 on CVE-2024-29954, CVE-2025-9711, CVE-2026-0383.
#
# `lettered`, and here that is not a spelling nicety like it is for Cisco:
# Fabric OS numbers its rebuilds 9.2.1c3 / 9.1.1d / 8.2.3e and NVD states
# the bounds in the same shape, so the strict _clean_version reads the
# installed 9.0.1e1 as not-a-version and the switch is never scanned at
# all. The letters are then ORDERED rather than dropped — see _vparse.
#
# Brocade's IP switches (ICX/FastIron, NetIron) run different firmware
# under the same brand. They are kept out on the Netdisco side, where the
# os slug still tells them apart — see netdisco_service.brocade_family.
(re.compile(r"^(brocade\s+|broadcom\s+)?fabric\s*os\b", re.I),
{"key": "cpe:broadcom:fabric_operating_system",
"cpe": "cpe:2.3:o:broadcom:fabric_operating_system",
"label": "Brocade Fabric OS", "lettered": True}),
# NetScaler ADC / Gateway, the sixth Netdisco firmware line. One appliance
# firmware under two CPEs with identical ranges, so both are queried; the
# pre-2023 names (application_delivery_controller_firmware,
# netscaler_gateway_firmware) answer nothing for a 13.1 or 14.1 build.
#
# `netscaler` reads the build as NVD writes it, "14.1-72.61", which the
# strict reader rejects for the hyphen. It is validated by
# cvelistv5_scan_service.netscaler_build, which also withholds the FIPS
# build lines.
#
# `skip_editions`: NVD states the FIPS and NDcPP builds as sw_edition
# entries with their own bounds, and its virtualMatchString does not filter
# on them. Verified 15.09.2026: a patched 14.1-73.32 still gets
# CVE-2026-19490 back, from the fips entry "14.1-66.68 .. 14.1-73.32
# including". A normal appliance is never a FIPS build.
#
# The cvelistV5 half is cvelistv5_scan_service.scan_asset_netscaler, and it
# is load-bearing: CVE-2026-19489 was "Awaiting Analysis" with no CPE.
(re.compile(r"^(citrix\s+)?netscaler\s+(adc|gateway)\b", re.I),
{"key": "cpe:citrix:netscaler_adc",
"cpe": "cpe:2.3:a:citrix:netscaler_application_delivery_controller",
"also": ["cpe:2.3:a:citrix:netscaler_gateway"],
"label": "NetScaler ADC", "netscaler": True,
"skip_editions": ["fips", "ndcpp"]}),
# Kemp LoadMaster, the seventh Netdisco firmware line. NVD states one range
# per release line (GA, LTSF 7.2.54, EOL 7.2.48, 7.1.35), and the unbounded
# ones overlap: a patched LTSF or 7.1.35 box sits inside another line's
# range. `loadmaster` hands a CVE's ranges to
# cvelistv5_scan_service.loadmaster_fix, which decides per line; the
# cvelistV5 half is scan_asset_loadmaster.
(re.compile(r"^(kemp\s+|progress\s+)?loadmaster\b", re.I),
{"key": "cpe:progress:loadmaster", "cpe": "cpe:2.3:o:progress:loadmaster",
"label": "Kemp LoadMaster", "loadmaster": True}),
# Sophos Firewall (SFOS), the eighth Netdisco firmware line. firewall_firmware
# carries the records since 2022; a:sophos:firewall adds CVE-2022-3236 and
# CVE-2023-5552, the latter nowhere else structured. The other names NVD used
# (o:sophos:firewall, sfos, xg_firewall_firmware) hold only CVE-2022-1807,
# which cvelistV5 has, and pre-2021 CVEs no advisory row covers, so they are
# not queried (checked 16.09.2026). The bounds ignore the
# hotfixes Sophos installs without changing the version, so `sophos` hands
# each matched CVE to cvelistv5_scan_service.sophos_fix, which applies the
# advisory's hotfixed and per-line fixed releases; the cvelistV5 half is
# scan_asset_sophos.
(re.compile(r"^sophos\s+firewall\b", re.I),
{"key": "cpe:sophos:firewall_firmware", "cpe": "cpe:2.3:o:sophos:firewall_firmware",
"also": ["cpe:2.3:a:sophos:firewall"],
"label": "Sophos Firewall", "sophos": True}),
(re.compile(r"ipad", re.I), {"key": "cpe:apple:ipados",
"cpe": "cpe:2.3:o:apple:ipados", "label": "Apple iPadOS"}),
(re.compile(r"ios|iphone", re.I), {"key": "cpe:apple:iphone_os",
"cpe": "cpe:2.3:o:apple:iphone_os", "label": "Apple iOS"}),
# ANCHORED, unlike the loose "ios|iphone" this used to be. Netdisco reports
# a Cisco switch's OS as literally "ios" and vendor+os reads "cisco ios" —
# matched as a substring, every Cisco switch in the estate would have been
# compared against Apple's iPhone CVE ranges, and a 15.2 IOS release sits
# below every iOS bound ever written. Intune/Defender/Wazuh all report the
# Apple string as "iOS"/"iPadOS"/"iPhone OS", which still match.
(re.compile(r"^(apple\s+)?(ios|iphone(\s*os)?)(\s|$)", re.I),
{"key": "cpe:apple:iphone_os",
"cpe": "cpe:2.3:o:apple:iphone_os", "label": "Apple iOS"}),
(re.compile(r"mac ?os|macos|mac_os|os x", re.I), {"key": "cpe:apple:macos",
"cpe": "cpe:2.3:o:apple:macos", "label": "Apple macOS"}),
]
@@ -416,11 +610,22 @@ def _os_family(os_name: str) -> Optional[str]:
return "windows"
if "ipad" in n:
return "ipados"
if "iphone" in n or "ios" in n:
# ANCHORED, for the reason the Apple entry in _OS_REGISTRY is: "ios" as a
# substring also lives inside "cisco ios", which is how Netdisco names a
# Cisco switch's firmware. Read as Apple's, a switch on 15.2 would be
# compared against the iOS 15 train by scan_asset_os_apple.
if re.match(r"^(apple\s+)?(iphone|ios)(\s|$)", n):
return "iphone_os"
if "android" in n:
return "android"
if "mac" in n or "darwin" in n or "os x" in n:
# "os x" is WORD-BOUNDED, for exactly the reason the iPhone pattern above
# is anchored — and it is the same trap one letter further along. "os x"
# lives inside "cisco iOS XE"/"cisco iOS XR" as a plain substring, so a
# router was read as a Mac: scan_asset_os_apple would then compare its
# 17.15.4c against macOS ranges (below every macOS 26 bound, so it matched
# them), and _platform_ok would drop the router's own NVD findings for
# naming the wrong platform.
if "mac" in n or "darwin" in n or re.search(r"\bos x\b", n):
return "macos"
# IGEL OS is a Debian-based Linux, and its CNA says so — CVE-2025-34082
# lists platforms ["Linux"]. Placing it here is what lets the platform
@@ -556,6 +761,70 @@ def _clean_version(v: str) -> Optional[str]:
return core if re.fullmatch(r"\d+(\.\d+)*(p\d+)?", core) else None
# Everything after the dotted-numeric head — "17.15.4c" → "c", "3.10.1xbS" →
# "xbs", "2.4.0p12" → "p12", "16.11.0016" → "".
_VHEAD_RE = re.compile(r"^\d+(?:\.\d+)*")
# _clean_version, plus a TRAIN/REBUILD LETTER — "17.15.4c", "3.10.1xbS".
#
# Kept apart from _clean_version rather than folded into it, deliberately.
# That rule is shared by thirteen call sites across six services, and its
# strictness is doing a job everywhere else: it is what keeps distro package
# versions out of the NVD queries, and — more to the point — every one of those
# paths compares versions as RANGES, where _vtuple drops the letters. Letting
# "1.1.1w" through there would hand a letter-blind comparison a version whose
# letter is the release (OpenSSL is the obvious victim: 1.1.1a .. 1.1.1w all
# read as (1,1,1)).
#
# Only a registry entry marked `lettered` gets this reader, and only the two
# Cisco ones are, because their match is EXACT — the one comparison where the
# suffix is honoured in full (see _vsuffix / _in_range).
# ponytail: 1-3 letters + optional digits covers every Cisco train letter
# observed (39 distinct suffixes across the IOS XE release history: "c", "SG",
# "aSP", "xbS", "z2"). A longer one falls back to "no version", which means no
# scan — the safe direction. Widen it when one turns up.
_LETTERED_RE = re.compile(r"\d+(\.\d+)*([a-z]{1,3}\d*)?", re.I)
def _lettered_version(v: str) -> Optional[str]:
"""Release version for a product whose rebuilds carry a letter, or None.
Returned in the spelling the SOURCES use, because for these products the
match is exact and a spelling difference is a miss:
* lowercased Cisco writes "3.2.0SG" and NVD's CPE writes "3.2.0sg"
(CPE version components are lowercase by spec);
* zero-padding dropped per component Cisco's own release notes write
one build as both "17.09.05a" and "17.9.5a", and only the second
spelling exists in NVD and cvelistV5.
"""
v = (v or "").strip()
if not v or ":" in v:
return None
core = re.split(r"[ (+]", v, 1)[0]
if not _LETTERED_RE.fullmatch(core):
return None
core = core.lower()
nums, suffix = _VHEAD_RE.match(core).group(0), core[_VHEAD_RE.match(core).end():]
return ".".join(str(int(x)) for x in nums.split(".")) + suffix
def _vsuffix(v: str) -> str:
"""The release suffix of a version, lowercased — "" when there is none.
Vendors that ship rebuilds letter them, and the letter IS the release: NVD
lists cisco:ios_xe 17.15.4, 17.15.4a, 17.15.4b, 17.15.4c and 17.15.4d as
five separate CPEs, and a given CVE names some of them and not others.
_vtuple reads all five as (17,15,4), so a numeric comparison calls them
equal which is right for a RANGE bound (a range has a floor and a
ceiling to confine it) and wrong for an EXACT version, where equality is
the whole verdict.
"""
t = (v or "").strip()
m = _VHEAD_RE.match(t)
return t[m.end():].lower() if m else ""
# Adobe renamed its version scheme mid-life: Acrobat/Reader DC shipped as
# 2019.010.20098 (four-digit year) until the 2020 release, then dropped the
# century — 20.001.30005, and today 26.001.21771. Both spellings mean the same
@@ -606,8 +875,47 @@ def _vtuple(v: str) -> Optional[tuple]:
return tuple(int(x) for x in nums[:6])
# A dotted version with a trailing RELEASE LETTER and optional rebuild digit —
# "9.2.1c3", "8.2.3e", "1.1.1w". Brocade numbers every Fabric OS build this way
# and states its bounds in the same shape, and the letter is the release: 9.2.1a
# is older than 9.2.1c3, which is older than 9.2.1d.
_SUFFIXED_V_RE = re.compile(r"^(\d+(?:\.\d+)*)([a-z]{1,3})(\d*)$", re.I)
def _vparse(v: str) -> Optional[tuple]:
"""(numbers, letter-ordinals, rebuild) for a suffix-lettered version.
None when the string is not a plain dotted version with at most a trailing
release letter the caller falls back to _vtuple then.
This exists because _vtuple reads the letter's digits as another numeric
component and the letter itself as nothing: "9.2.1c3" becomes (9,2,1,3) and
"9.2.1d" becomes (9,2,1). Compared against a bound of "below 9.2.1c3", a
switch already on 9.2.1d sits below it and collects a CVE it was patched
for. Sorting the letters is the only way to order builds a vendor numbers
this way.
"""
t = (v or "").strip()
m = _SUFFIXED_V_RE.match(t)
if m:
nums, letters, tail = m.group(1), m.group(2).lower(), m.group(3)
elif re.fullmatch(r"\d+(\.\d+)*", t):
nums, letters, tail = t, "", ""
else:
return None
ns = tuple(int(x) for x in nums.split("."))[:6]
return (ns + (0,) * (6 - len(ns)),
tuple(ord(c) - 96 for c in letters), int(tail or 0))
def _vcmp(a: str, b: str) -> Optional[int]:
"""-1/0/1, or None when not comparable."""
# Only when a letter is actually in play, so every all-numeric comparison —
# which is nearly all of them, Adobe's year rewrite included — keeps going
# through _vtuple exactly as before.
pa, pb = _vparse(a), _vparse(b)
if pa and pb and (pa[1] or pb[1]):
return (pa > pb) - (pa < pb)
ta, tb = _vtuple(a), _vtuple(b)
if ta is None or tb is None:
return None
@@ -706,6 +1014,27 @@ def _in_range(installed: str, m: dict) -> bool:
# could still arrive through NVD.
if any(b is not None and not is_version(b) for b in (sI, sE, eI, eE)):
return False
if (sI or sE) and not (eI or eE):
# "From X upward, no ceiling" is not a per-host verdict. NVD writes it
# when nobody recorded where the flaw was fixed, and read literally it
# says every release the vendor will EVER ship is affected — a finding
# that can never be closed by patching, which is the definition of
# noise here.
#
# CVE-2019-12660 is the case that surfaced it: its entire NVD
# configuration is ONE entry, `cisco:ios_xe` with
# versionStartIncluding 16.1.1 and no end bound at all. Cisco fixed it
# in 2019 and its advisory says "at the time of publication ... 16.x
# and later"; the 17.15 train did not exist until 2024. Every IOS XE
# router in the estate carried it, with "fixed in: n/a", for good.
#
# This is the rule BOTH other range readers already apply — see
# _ranges_from_affected ("only ranges with a real upper bound") and
# vmware_entries ("affected, fix unknown cannot be turned into a
# per-host verdict") — so this half was the outlier, not the fix.
# Per ENTRY, not per CVE: a record that also carries a bounded entry
# still reports through that one.
return False
if _is_adobe_acrobat(m.get("criteria", "")):
for b in (sI, sE, eI, eE):
if b and not _same_version_scheme(installed, b):
@@ -717,7 +1046,16 @@ def _in_range(installed: str, m: dict) -> bool:
ver = parts[5] if len(parts) > 5 else "*"
if ver in ("*", "-", ""):
return False # wildcard → would match everything → skip (FP)
return _vcmp(installed, ver) == 0
# BOTH halves. The numbers alone are not the version when the vendor
# letters its rebuilds: cisco:ios_xe 17.15.1, 17.15.1w, 17.15.1x and
# 17.15.1y all read as (17,15,1), so a host on 17.15.1y matched every
# CVE that names only the base 17.15.1 — three false positives out of
# four on any Cisco router. The suffix is compared as a string because
# that is all it is; the numeric half stays for the spellings that
# differ without meaning to (Adobe's 19.010 vs 2019.010, SAP's 8.0 vs
# 8.00), which is why this is not a plain string equality.
return (_vcmp(installed, ver) == 0
and _vsuffix(installed) == _vsuffix(ver))
if sI is not None and (_vcmp(installed, sI) is None or _vcmp(installed, sI) < 0):
return False
if sE is not None and (_vcmp(installed, sE) is None or _vcmp(installed, sE) <= 0):
@@ -751,7 +1089,9 @@ def _query_osv(eco: str, name: str, version: str) -> List[dict]:
return out
def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List[dict]:
def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
skip_editions: Optional[list] = None,
loadmaster: bool = False, sophos: bool = False) -> List[dict]:
api_key = os.getenv("NVD_API_KEY", "").strip()
headers = {"apiKey": api_key} if api_key else None
url = f"{NVD_CVE_API}?virtualMatchString={cpe}:{version}&resultsPerPage=200"
@@ -790,14 +1130,22 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
matched = False
fixed = None
tsws: set = set()
lm_ranges: list = []
for cfg in cve_obj.get("configurations", []) or []:
for node in cfg.get("nodes", []) or []:
for m in node.get("cpeMatch", []) or []:
crit = m.get("criteria") or ""
if prod_token not in crit:
continue
if prod_token not in crit and not (
loadmaster and ":progress:multi-tenant_" in crit):
continue # Multi Tenant: its 7.1.35 line, see loadmaster_fix
if not m.get("vulnerable", True):
continue
if skip_editions and (crit.split(":") + [""] * 10)[9].lower() \
in skip_editions:
continue # sw_edition, see the registry entry
if loadmaster:
lm_ranges.append(_loadmaster_range(m))
continue # decided per CVE below
ok = (_sap_affected(m, version, sap_pl) if sap_pl is not None
else _in_range(version, m))
if ok:
@@ -830,6 +1178,14 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
end_incl = m.get("versionEndIncluding")
if end_incl and is_version(end_incl):
fixed = fixed or f">{end_incl}"
if loadmaster:
from app.services.cvelistv5_scan_service import loadmaster_fix
fixed = loadmaster_fix(version, lm_ranges, cve_id)
matched, tsws = bool(fixed), {"*"}
if sophos:
from app.services.cvelistv5_scan_service import sophos_fix
fixed = sophos_fix(version, cve_id) if matched else None
matched, tsws = bool(fixed), {"*"}
if not matched:
continue
cvss, sev = _nvd_cvss(cve_obj)
@@ -848,6 +1204,19 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List
return out
def _loadmaster_range(m: dict) -> Optional[tuple]:
"""One cpeMatch entry as a LoadMaster range (see loadmaster_fix)."""
from app.services.cvelistv5_scan_service import loadmaster_range
sI, sE = m.get("versionStartIncluding"), m.get("versionStartExcluding")
eI, eE = m.get("versionEndIncluding"), m.get("versionEndExcluding")
if not any((sI, sE, eI, eE)):
exact = (m.get("criteria") or "").split(":")[5:6]
if not exact or exact[0] in ("*", "-"):
return None
return loadmaster_range(exact[0], True, exact[0], True)
return loadmaster_range(sI or sE, bool(sI), eE or eI, not eE)
def _nvd_cvss(cve_obj: dict) -> Tuple[Optional[float], Optional[str]]:
metrics = cve_obj.get("metrics", {}) or {}
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
@@ -910,12 +1279,16 @@ def lookup_cves(db: Session, entry: dict, version: str,
if entry["kind"] == "osv":
cves = _query_osv(entry["eco"], entry["oname"], version)
else:
cves = _query_nvd_cpe(entry["cpe"], version, sap_pl=sap_pl)
skip = entry.get("skip_editions")
sophos = bool(entry.get("sophos"))
cves = _query_nvd_cpe(entry["cpe"], version, sap_pl=sap_pl, skip_editions=skip,
loadmaster=bool(entry.get("loadmaster")), sophos=sophos)
# Merge the alternate CPE names, keeping the first entry per CVE —
# a duplicate across names is the same finding, not two.
for alt in entry.get("also") or []:
seen_ids = {c["cve"] for c in cves}
cves += [c for c in _query_nvd_cpe(alt, version, sap_pl=sap_pl)
cves += [c for c in _query_nvd_cpe(alt, version, sap_pl=sap_pl,
skip_editions=skip, sophos=sophos)
if c["cve"] not in seen_ids]
except _TransientNVD as e:
logger.warning("app-cve: %s — not caching, will retry next run", e)
@@ -1029,11 +1402,23 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
existing.fixed_version = c["fixed"]
# The advisory URL, for sources whose id has no public catalogue page:
# a TV-2026-1006 finding has no NVD, no CVE.org and no GHSA record, so
# the vendor bulletin is its ONLY reference. Fill-only, like the rest.
if c.get("refs") and not existing.references:
existing.references = c["refs"]
# the vendor bulletin is its ONLY reference. Merged rather than
# fill-only: another source may already have written its own evidence
# (the M365 check attaches its release-notes section), and that must
# not cost this finding its bulletin.
if c.get("refs"):
try:
_urls = json.loads(c["refs"])
except (TypeError, ValueError):
_urls = []
for _url in _urls if isinstance(_urls, list) else []:
existing.add_reference(_url)
if c.get("severity") and existing.severity == VulnerabilitySeverity.medium:
existing.severity = sev_map.get(c["severity"].lower(), existing.severity)
# ("TeamViewer", "Critical") from sources with a rating of their own;
# NVD and cvelistV5 carry none.
if c.get("vendor_sev"):
existing.set_vendor_severity(*c["vendor_sev"])
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="App CVE scan detects this CVE on the host again", source="app_scan")
try:
@@ -1055,6 +1440,8 @@ def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: l
detected_at=datetime.now(),
sources=json.dumps(["app-scan"]), first_detected_by="app-scan",
)
if c.get("vendor_sev"):
row.set_vendor_severity(*c["vendor_sev"])
db.add(row)
db.flush()
from app.services.audit_events import record_affected_package
@@ -1082,9 +1469,13 @@ def _is_citrix_shim(pkg: dict) -> bool:
"""Citrix published-app delivery leaves a registry stub ('Firefox 1.0',
vendor 'Delivered by Citrix') for software that is NOT installed on the
box matching it produced ancient-CVE false positives (observed:
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub."""
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub.
The delivery phrase, not the bare word: "citrix" also dropped every real
Citrix install ("Citrix Workspace 2507", vendor "Citrix Systems, Inc."),
so no Citrix CVE ever reached a host (CVE-2026-78546/-78547)."""
vendor = (pkg.get("vendor") or "").lower()
return "citrix" in vendor
return "delivered by citrix" in vendor
def filter_inventory(packages: list) -> list:
@@ -1133,9 +1524,43 @@ _EXTENSION_REGISTRY: Dict[str, dict] = {
"elhekieabhbkpmcefcoobjddigjcaadp": {
"key": "ext:adobe:acrobat:edge", "cpe": "cpe:2.3:a:adobe:acrobat",
"label": "Adobe Acrobat (User) extension (Edge)"},
# Fortinet Privileged Access Agent (FortiPAM). No "cpe": NVD and cvelistV5
# had no record of CVE-2026-84388 ten days after Fortinet published it
# (FG-IR-26-168, 2026-09-08), so there is nothing to look up and the
# vendor's own statement is the only source. Its CVRF lists 8.0.0 and
# 8.0.1 as Known Affected; the advisory text names the fix as extension
# 8.0.1.123 (the store build of 2026-08-01). The extension version is the
# advisory's version plus a build number, so the range compares directly.
# 7.x is NOT listed as affected in the CVRF and stays out until Fortinet
# says otherwise. The server half of the fix (FortiPAM 1.9.1 / 1.8.4) is
# not visible from a workstation.
"gkmiciealelfckimnoopefeplibjciih": {
"key": "ext:fortinet:privileged_access_agent:chrome",
"label": "Fortinet Privileged Access Agent (User) extension (Chrome)",
"vendor": "Fortinet",
"advisories": [{
"cve": "CVE-2026-84388", "start": "8.0.0", "fix": "8.0.1.123",
"cvss": 9.1, "severity": "critical",
"desc": ("An improper authentication vulnerability [CWE-287] in the "
"Fortinet Privileged Access Agent Chrome Extension may allow a "
"remote unauthenticated attacker to proxy a user's browser "
"traffic through attacker controlled servers if the user "
"visits a malicious website. Also upgrade the FortiPAM server "
"to 1.9.1 or 1.8.4."),
"refs": '["https://fortiguard.fortinet.com/psirt/FG-IR-26-168"]'}]},
}
def _advisory_cves(entry: dict, version: str) -> List[dict]:
"""The registry's vendor-advisory rows that cover `version`, in the shape
lookup_cves returns. Bounded both ends, like every other range here."""
return [{"cve": a["cve"], "cvss": a["cvss"], "severity": a["severity"],
"desc": a["desc"], "fixed": a["fix"], "refs": a["refs"]}
for a in entry.get("advisories") or []
if _in_range(version, {"versionStartIncluding": a["start"],
"versionEndExcluding": a["fix"]})]
def resolve_extension(ext: dict) -> Optional[dict]:
"""Curated lookup by store id. Unknown extensions are ignored, same rule
as the package registry no guessing from names."""
@@ -1184,12 +1609,13 @@ def scan_asset_extensions(db: Session, asset, extensions: list,
if key in seen:
continue # same extension in several browser profiles
seen.add(key)
try:
cves = lookup_cves(db, {"key": entry["key"], "kind": "cpe",
"cpe": entry["cpe"]}, version)
except Exception as e:
logger.debug("extension lookup failed (%s): %s", entry["label"], e)
continue
cves = _advisory_cves(entry, version)
if entry.get("cpe"):
try:
cves += lookup_cves(db, {"key": entry["key"], "kind": "cpe",
"cpe": entry["cpe"]}, version)
except Exception as e:
logger.debug("extension lookup failed (%s): %s", entry["label"], e)
for c in cves:
# Here target_sw is the BROWSER, not the OS — that is exactly the
# field that separates the extension's CVEs from the desktop
@@ -1200,7 +1626,7 @@ def scan_asset_extensions(db: Session, asset, extensions: list,
try:
_upsert(db, asset, label, ext.get("version") or version,
c, new_ids, touched=touched,
vendor=(ext.get("vendor") or "Adobe"))
vendor=(ext.get("vendor") or entry.get("vendor", "Adobe")))
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("extension upsert failed (%s on %s): %s",
@@ -1271,11 +1697,23 @@ def scan_asset_os(db: Session, asset, new_ids: list, touched: Optional[set] = No
e = _resolve_os(asset.operating_system or "")
if not e:
return 0
cver = _clean_version(asset.os_version or "")
if e.get("netscaler"):
from app.services.cvelistv5_scan_service import netscaler_build
cver = asset.os_version.strip() if netscaler_build(asset.os_version) else None
elif e.get("sophos"):
from app.services.cvelistv5_scan_service import sophos_version
cver = sophos_version(asset.os_version) # "22.0.1 MR-1-Build490" → "22.0.1.490"
else:
read = _lettered_version if e.get("lettered") else _clean_version
cver = read(asset.os_version or "")
if not cver:
return 0
try:
cves = lookup_cves(db, {"key": e["key"], "kind": "cpe", "cpe": e["cpe"]}, cver)
cves = lookup_cves(db, {"key": e["key"], "kind": "cpe", "cpe": e["cpe"],
"also": e.get("also"),
"skip_editions": e.get("skip_editions"),
"loadmaster": e.get("loadmaster"),
"sophos": e.get("sophos")}, cver)
except Exception as ex:
logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
return 0
@@ -1400,11 +1838,21 @@ def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
return resolved
def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
def run_app_cve_scan(db: Session, asset_id: Optional[int] = None,
trigger: str = "manual") -> dict:
"""Scan all assets with software inventory (Wazuh packages + Intune
detectedApps) app-scan CVEs, then enrich the new ones."""
detectedApps) app-scan CVEs, then enrich the new ones.
`trigger` names who started it in the sync-run ledger when the Wazuh
API turns out to be dead the scan logs in to it for every agent's
package list, so it is the third job of the night to find that out."""
from app.models.asset import Asset, AssetSource
from app.services.sync_run_service import probe_wazuh_api, report_source_failure, source_failure
from app.services.audit_events import asset_inventory, use_inventory
stats = {"assets": 0, "findings": 0, "new": 0, "errors": []}
from app.services.cvelistv5_scan_service import take_sophos_no_row
take_sophos_no_row() # this run's CVEs only
wazuh_errors: list = [] # package/extension fetches only — NVD/OSV 503s are not Wazuh
new_ids: list = []
logger.info("App CVE scan starting: NVD key %s",
"present" if os.getenv("NVD_API_KEY", "").strip() else "MISSING (keyless = frequent 503)")
@@ -1425,6 +1873,14 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
verify_ssl=bool(cfg.get("verify_ssl", True)))
except Exception as e:
logger.debug("app-cve: wazuh client unavailable: %s", e)
# One login up front. Refused → recorded as a failed Wazuh run (mail,
# cooldown) and the scan goes on without Wazuh inventory: every Wazuh
# asset then reports an EMPTY inventory and keeps its findings (see the
# auto-resolve guard below), Intune-only assets are scanned as usual.
if wazuh and probe_wazuh_api(wazuh, phase="app-scan packages", trigger=trigger):
stats["errors"].append("wazuh: login refused — Wazuh inventory skipped this run")
stats["wazuh_skipped"] = True
wazuh = None
try:
from app.services.intune_service import load_intune_config, _build_client
icfg = load_intune_config(db)
@@ -1449,12 +1905,48 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
except Exception as e:
logger.warning("app-cve: cvelistV5 index build/load failed: %s", e)
isn_index = {}
try:
from app.services import igel_isn_service
isn_index = igel_isn_service.get_index(db)
except Exception as e:
logger.warning("app-cve: IGEL ISN index load failed: %s", e)
# MSRC fixed-build index (Windows Server, SharePoint, Edge). Rebuilt at
# 01:30 by vuln_index_refresh_nightly and loaded here ONCE — it used to be
# parsed from its JSON blob again for every asset.
#
# ensure_index, not load_index: load_index serves the stored blob at any
# age, so this pass decided from whatever the last nightly build left
# behind. Moving that build to 01:30 fixed the nightly order but not a run
# started by hand — after the field report the operator ran MSRC enrichment
# and an app scan from the GUI and still saw no Edge finding, because
# neither touches this index and the stored one predated the CVE. A scan
# someone presses a button for must not answer out of yesterday's index;
# 6h keeps the nightly chain on the 01:30 build. A failed rebuild serves
# the cache (see ensure_index) — an offline MSRC is no worse than before.
m_index = {}
try:
from app.services import msrc_scan_service
m_index = msrc_scan_service.ensure_index(
db, max_age=msrc_scan_service.SCAN_INDEX_MAX_AGE) or {}
except Exception as e:
logger.warning("app-cve: MSRC index build/load failed — MSRC products "
"skipped this run: %s", e)
m_index = {}
q = db.query(Asset)
if asset_id is not None:
q = q.filter(Asset.id == asset_id)
for asset in q.all():
touched = False
touched_cves: set = set() # every CVE re-detected this run → reconcile base
# Whose picture of this host are we deciding from? A reopen may not
# undo a close that is NEWER than the data claiming it (see
# audit_events._reopen_grace). The OS passes below read fields an
# inventory sync wrote onto the asset, so start from the inventory that
# owns it; the package block refines this to the list that answered.
use_inventory(asset_inventory(asset))
# Marks the start of THIS asset's pass. Package rows not refreshed past
# it were not re-confirmed and get pruned below.
asset_scan_started = datetime.now()
@@ -1510,6 +2002,19 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
except Exception as e:
stats["errors"].append(f"asset {asset.id} vsphere: {e}")
# IGEL's own security notices (ISN) — endpoints AND the UMS
# server. Before scan_asset_igel so its hits land in
# `touched_cves` for that pass's reconcile.
try:
from app.services import igel_isn_service
n = igel_isn_service.scan_asset(db, asset, new_ids,
touched=touched_cves, index=isn_index)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} igel-isn: {e}")
# IGEL OS thin clients. Same shape as vSphere — the firmware
# version already sits on the asset, put there by the IGEL UMS
# connector — and the same reason for existing: an endpoint that
@@ -1527,7 +2032,121 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
except Exception as e:
stats["errors"].append(f"asset {asset.id} igel: {e}")
# HPE Aruba switches and controllers. Same shape as vSphere and
# IGEL — the firmware version already sits on the asset, put there
# by the Netdisco connector — and the same reason for existing: a
# switch runs no agent, enrols in no MDM and answers a network scan
# with an open port and no version.
# AFTER the CPE OS scan above on purpose: it folds that scan's hits
# into its own reconcile (see scan_asset_aruba).
try:
from app.services import cvelistv5_scan_service
n = cvelistv5_scan_service.scan_asset_aruba(
db, asset, cve5_index, new_ids, touched=touched_cves)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} aruba: {e}")
# Cisco IOS XE / IOS XR routers and switches. Same shape and same
# reason as the Aruba pass above — the firmware version sits on the
# asset, put there by the Netdisco connector — and AFTER the CPE OS
# scan for the same reason: it folds that scan's hits into its own
# reconcile (see scan_asset_cisco).
try:
from app.services import cvelistv5_scan_service
n = cvelistv5_scan_service.scan_asset_cisco(
db, asset, cve5_index, new_ids, touched=touched_cves)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} cisco: {e}")
# Extreme Networks EXOS switches. Third of the Netdisco firmware
# lines, same shape and same placement as the two above.
try:
from app.services import cvelistv5_scan_service
n = cvelistv5_scan_service.scan_asset_extreme(
db, asset, cve5_index, new_ids, touched=touched_cves)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} extreme: {e}")
# Ubiquiti UniFi access points. Fourth Netdisco firmware line, same
# shape and same placement — and the one pass that also reads the
# MODEL off the asset's OS string, because half of Ubiquiti's
# records apply to a single model (see scan_asset_ubiquiti).
try:
from app.services import cvelistv5_scan_service
n = cvelistv5_scan_service.scan_asset_ubiquiti(
db, asset, cve5_index, new_ids, touched=touched_cves)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} ubiquiti: {e}")
# NetScaler ADC / Gateway. Sixth Netdisco firmware line, same
# shape and placement; its bounds are a line plus a build, so it
# has a decision of its own (see scan_asset_netscaler).
try:
from app.services import cvelistv5_scan_service
n = cvelistv5_scan_service.scan_asset_netscaler(
db, asset, cve5_index, new_ids, touched=touched_cves)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} netscaler: {e}")
# Kemp LoadMaster, seventh line: one range per release line, decided
# per line (see scan_asset_loadmaster).
try:
from app.services import cvelistv5_scan_service
n = cvelistv5_scan_service.scan_asset_loadmaster(
db, asset, cve5_index, new_ids, touched=touched_cves)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} loadmaster: {e}")
# Sophos Firewall, eighth line: the range, then the advisory's
# hotfixed and per-line fixed releases (see scan_asset_sophos).
try:
from app.services import cvelistv5_scan_service
n = cvelistv5_scan_service.scan_asset_sophos(
db, asset, cve5_index, new_ids, touched=touched_cves)
if n:
stats["findings"] += n
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} sophos: {e}")
# Brocade Fabric OS has no pass here: its CNA states the affected
# versions as prose, so cvelistV5 carries nothing structured for it
# and the NVD-CPE scan above is the whole verdict.
# Package-level CVEs (Wazuh syscollector / Intune detectedApps).
#
# `inventory_complete` decides whether this run is allowed to CLOSE
# anything on this asset. Detecting from a partial inventory is
# harmless — an app that is there is there — but retracting needs the
# host's OWN, whole inventory. The two are tracked apart because they
# were conflated: the guard below asked only whether `packages` was
# non-empty, never where the list came from.
inventory_complete = True
# The source that owns this asset's software list, and the one that
# actually answered. A host registered in Wazuh AND Intune has Wazuh as
# its inventory; Intune's detectedApps is a thinner, differently-named
# list that does not carry every product syscollector reports.
primary_inv = "wazuh" if asset.wazuh_agent_id else (
"intune" if asset.intune_device_id else None)
served_inv = None
packages: list = []
try:
if asset.wazuh_agent_id and wazuh:
@@ -1538,11 +2157,31 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
packages = node_inventory(asset)
else:
packages = wazuh.get_packages(asset.wazuh_agent_id) or []
served_inv = "wazuh"
elif asset.intune_device_id and graph:
# Reached for an Intune-only asset — and, when the Wazuh login
# was refused up front, ALSO for a Wazuh asset that happens to
# be enrolled in Intune. That fall-through is kept (its
# findings are real), but it is not this host's inventory, so
# `served_inv` will not match `primary_inv` and nothing gets
# closed from it. Field report 06.09.2026: with the Wazuh API
# down, two CVEs on the one dual-registered host were closed as
# "no longer detected" against Intune's list, and the next scan
# after Wazuh came back reopened both.
packages = graph.get_detected_apps(asset.intune_device_id) or []
served_inv = "intune"
except Exception as e:
stats["errors"].append(f"asset {asset.id}: {e}")
if asset.wazuh_agent_id and wazuh:
wazuh_errors.append(f"{asset.hostname}: {e}")
packages = []
served_inv = None
if primary_inv is not None and served_inv != primary_inv:
inventory_complete = False
# Everything from here decides from `packages`, so the reopen grace
# follows THAT list's lag — including the fall-through case, where a
# Wazuh host was answered by Intune's older detectedApps.
use_inventory(served_inv or asset_inventory(asset))
# Filter once, here — every path below shares this list.
packages = filter_inventory(packages)
@@ -1561,6 +2200,12 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
touched = True
except Exception as e:
stats["errors"].append(f"asset {asset.id} extensions: {e}")
wazuh_errors.append(f"{asset.hostname} extensions: {e}")
# Extension findings carry the same 'app-scan' source as the
# package ones, and nothing else inventories an extension. A
# failed fetch here would otherwise leave them reconciled
# against a list that cannot contain them.
inventory_complete = False
if packages:
stats["findings"] += scan_asset_packages(db, asset, packages, new_ids, touched=touched_cves)
if cve5_index:
@@ -1606,20 +2251,18 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
# knows the patch state. Separate source ('msrc') + its own reconcile.
try:
from app.services import msrc_scan_service
m_index = msrc_scan_service.load_index(db)
if not m_index:
# Absent (or retired by a version bump) → build it now so a
# manual scan behaves like the nightly job, instead of
# silently skipping every MSRC product.
logger.info("app-cve: MSRC index missing — building now")
m_index = msrc_scan_service.build_product_index(db) or {}
if m_index:
m_touched: set = set()
stats["msrc_findings"] = stats.get("msrc_findings", 0) + \
msrc_scan_service.scan_asset_packages(
db, asset, packages, m_index, new_ids, touched=m_touched)
stats["msrc_resolved"] = stats.get("msrc_resolved", 0) + \
msrc_scan_service.resolve_stale_packages(db, asset, m_touched)
# Same rule as the app-scan reconcile below: this one
# closes MSRC package findings on the assumption that it
# just saw the whole inventory, so it must not run on a
# partial one.
if inventory_complete:
stats["msrc_resolved"] = stats.get("msrc_resolved", 0) + \
msrc_scan_service.resolve_stale_packages(db, asset, m_touched)
# OS-level MSRC findings too. Their scan+reconcile lived
# only in run_msrc_scan (nightly / its own button), so a
# patched Windows host kept its MSRC OS findings open until
@@ -1632,6 +2275,10 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
stats["msrc_os_resolved"] = stats.get("msrc_os_resolved", 0) + \
msrc_scan_service.resolve_stale_os(db, asset, os_touched)
except Exception as e:
# Loud: the nightly log prints stats without `errors`, so a
# pass that died here (no Edge finding, no reconcile) left no
# trace at all.
logger.warning("app-cve: MSRC pass failed on %s: %s", asset.hostname, e)
stats["errors"].append(f"asset {asset.id} msrc: {e}")
# Auto-resolve: an app-scan-only finding no longer re-detected means
# the software was updated/removed past it. Only safe when we had a
@@ -1658,6 +2305,14 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
# cannot trust, never a host that runs no software — so leave its
# findings alone. A finding held open one cycle too long is a far
# smaller error than several hundred closed on no evidence.
#
# An inventory that is merely INCOMPLETE is the same problem with
# a full-looking list: the Wazuh login was refused and Intune's
# detectedApps answered for a dual-registered host, or the
# extension fetch died. Non-empty, so the check above passes, and
# everything the answering source does not list reads as "no
# longer detected". Only the host's own, whole inventory may
# retract a finding.
from app.models.asset import AssetStatus
if not packages and (asset.wazuh_agent_id or asset.intune_device_id):
stats["resolve_skipped_no_inventory"] = \
@@ -1665,6 +2320,12 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
logger.info("app-cve: %s reported an EMPTY inventory — skipping "
"auto-resolve (agent re-registered, or a failed fetch)",
asset.hostname)
elif not inventory_complete:
stats["resolve_skipped_partial_inventory"] = \
stats.get("resolve_skipped_partial_inventory", 0) + 1
logger.info("app-cve: %s inventory was incomplete this run "
"(%s answered, %s owns it) — skipping auto-resolve",
asset.hostname, served_inv or "nothing", primary_inv)
elif asset.status == AssetStatus.ACTIVE:
# Prune first: a finding may stay open on one product while
# another of its products is already patched.
@@ -1690,9 +2351,15 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
stats["assets"] += 1
db.commit()
use_inventory(None)
if graph:
graph.close()
stats["new"] = len(new_ids)
# The API dying mid-scan leaves auth/5xx/connection errors on the assets
# after it — a Wazuh failure, not per-asset noise.
hit = source_failure(wazuh_errors)
if hit:
report_source_failure("wazuh", trigger, "app-scan packages", hit)
# Enrich + audit the new app-scan CVEs.
if new_ids:
@@ -1750,6 +2417,9 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
except Exception as e:
logger.warning("app-cve: FP-suppression failed (non-fatal): %s", e)
no_row = take_sophos_no_row()
if no_row:
stats["errors"].append(no_row)
logger.info("App CVE scan: %d assets, %d findings (%d new, %d auto-resolved, %d FP-suppressed)",
stats["assets"], stats["findings"], stats["new"],
stats.get("resolved", 0), stats.get("fp_suppressed", 0))
+126
View File
@@ -194,6 +194,61 @@ def apply_status(
return True
def rename(db: Session, asset: Asset, new_name: str, source: str) -> bool:
"""Set `asset.hostname` from a sync and log it, backend and audit log.
No-op if unchanged.
Every sync rename goes through here, for the same reason status changes go
through apply_status: a manual rename is audited by the asset router, and
without this a sync-driven one left no trace the old name, still in a
ticket or a mail, then pointed at nothing (field request 2026-09-18).
"""
old = asset.hostname
if not new_name or old == new_name:
return False
asset.hostname = new_name
logger.info("%s sync renamed asset %s: %s%s", source, asset.id, old, new_name)
try:
from app.models.audit_log import AuditLog, AuditEventType
db.add(AuditLog(
user_id=None,
event_type=AuditEventType.ASSET_UPDATED,
event_description=f"Asset renamed by {source} sync: {old}{new_name}"[:500],
resource_type="asset",
resource_id=str(asset.id),
old_value=old,
new_value=json.dumps({"hostname": new_name, "old_hostname": old, "source": source}),
timestamp=datetime.now(),
))
except Exception as e:
logger.warning("asset rename audit failed for %s: %s", asset.id, e)
return True
def created(db: Session, asset: Asset, source: str) -> None:
"""Write the ASSET_CREATED audit entry for an asset a sync just added.
Every sync create goes through here: a manual create has always been
audited by the asset router, a sync-created one only reached the backend
log (field request 2026-09-19).
"""
if asset.id is None:
db.flush()
try:
from app.models.audit_log import AuditLog, AuditEventType
db.add(AuditLog(
user_id=None,
event_type=AuditEventType.ASSET_CREATED,
event_description=f"Asset created by {source} sync: {asset.hostname}"[:500],
resource_type="asset",
resource_id=str(asset.id),
new_value=json.dumps({"hostname": asset.hostname, "source": source}),
timestamp=datetime.now(),
))
except Exception as e:
logger.warning("asset create audit failed for %s: %s", asset.id, e)
def _stamp_source(asset: Asset) -> str:
"""Human-readable name of the sync that wrote this asset's stamps.
@@ -716,3 +771,74 @@ def reconcile_igel_by_seen_ids(
stats["reactivated"] += 1
return stats
def reconcile_netdisco_by_seen_ids(
db: Session,
*,
seen_asset_ids: set,
reason: str,
) -> dict:
"""Id-keyed Netdisco reconcile — same robust pattern as the IGEL one.
Candidate set = ACTIVE Netdisco-known assets (source == NETDISCO OR
netdisco_device_ip IS NOT NULL) minus the ids seen this sync INACTIVE;
INACTIVE ones seen again ACTIVE.
What this does NOT reconcile away: a switch that is merely unreachable
right now. Netdisco keeps a device in its inventory until it is deleted or
ages out of discovery, so a reboot or a maintenance window never reaches
this function only a device Netdisco no longer lists at all does, which
is what a decommissioned switch looks like.
Fail-open on an empty seen set. DECOMMISSIONED untouched. Caller commits.
"""
from sqlalchemy import or_ as _or
stats = {"inactivated": 0, "reactivated": 0, "candidates": 0}
if not seen_asset_ids:
logger.warning(
"netdisco reconcile: skipped — seen_asset_ids empty "
"(sync returned no devices? not deactivating anything)"
)
return stats
netdisco_known = _or(
Asset.source == AssetSource.NETDISCO,
Asset.netdisco_device_ip.isnot(None),
)
candidates = (
db.query(Asset)
.filter(
netdisco_known,
Asset.status == AssetStatus.ACTIVE,
~Asset.id.in_(seen_asset_ids),
)
.all()
)
stats["candidates"] = len(candidates)
for asset in candidates:
old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
asset.status = AssetStatus.INACTIVE
_audit_asset_status(db, asset, old, "inactive", reason)
stats["inactivated"] += 1
revived = (
db.query(Asset)
.filter(
netdisco_known,
Asset.status == AssetStatus.INACTIVE,
Asset.id.in_(seen_asset_ids),
)
.all()
)
for asset in revived:
asset.status = AssetStatus.ACTIVE
_audit_asset_status(
db, asset, "inactive", "active",
"seen again by a Netdisco sync (event-driven revive)",
)
stats["reactivated"] += 1
return stats
+189
View File
@@ -0,0 +1,189 @@
"""Wann eine IP-Adresse ein Asset identifiziert — und wann nicht.
Jede Sync-Match-Kette endet auf derselben letzten Stufe: kein stabiler
Identifier getroffen, kein Hostname getroffen, also die Adresse. Die Stufe
existiert aus einem guten Grund ein ESXi steht in vCenter oft nur mit
seiner Management-Adresse, und der Nessus-Scan derselben Box soll auf dieses
Asset laufen statt ein zweites anzulegen.
Sie war aber bedingungslos, und eine Adresse ist kein Identifikator: Gastnetze,
VPN-Pools, VLAN-Segmente und recycelte DHCP-Leases vergeben dieselbe IP
mehrfach. Field report 2026-09-10: ein abgeschalteter Wazuh-Agent und ein
UniFi AP teilten sich eine Adresse und damit eine asset_id Hostname von
der einen Quelle, OS von der anderen, und der Status kippte bei jedem Lauf
zwischen INACTIVE (Wazuh, Keepalive uralt) und ACTIVE (Netdisco).
Die Adresse matcht daher nur noch, wenn sie eindeutig ist UND kein Name
widerspricht. Der Konsolidierungsfall oben überlebt: dort trägt eine der
beiden Seiten gar keinen Namen, sondern ihre eigene Adresse.
"""
from __future__ import annotations
import ipaddress
import logging
from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset
logger = logging.getLogger(__name__)
def _nameless(name: Optional[str]) -> bool:
"""Eine Quelle, die keinen Namen kennt — oder die Adresse als Namen führt.
Beides heißt: von dieser Seite kommt kein Widerspruch. Ein Asset, das nach
seiner Adresse benannt ist, wurde genau deshalb so angelegt.
"""
n = (name or "").strip()
if not n:
return True
try:
ipaddress.ip_address(n)
return True
except ValueError:
return False
def _same_host(a: Optional[str], b: Optional[str]) -> bool:
"""FQDN und Kurzform desselben Hosts gelten als derselbe Name."""
x = (a or "").strip().lower().split(".")[0]
y = (b or "").strip().lower().split(".")[0]
return bool(x) and x == y
def different_hosts(a: Optional[str], b: Optional[str]) -> bool:
"""Beide Seiten führen einen echten Namen, und es ist nicht derselbe."""
return not (_nameless(a) or _nameless(b) or _same_host(a, b))
def _not_pinned_by(q, cols):
"""Assets ohne Pin in `cols` — den Identitätsspalten einer Quelle, deren
Geräte nie dieses Gerät sein können. Ein Thin Client ist kein Netdisco-
Gerät und umgekehrt; trägt ein Asset den Pin der anderen Quelle, gehört es
einem anderen Gerät (Field report 2026-09-15: IGEL und Netdisco schrieben
jede Nacht abwechselnd einen Thin Client und einen UniFi AP auf eine Zeile).
"""
for col in cols:
q = q.filter(col.is_(None))
return q
def match_by_ip(db: Session, ip: Optional[str], name: Optional[str] = None,
*, not_pinned_by: tuple = ()) -> Optional[Asset]:
"""Das Asset, das diese Adresse identifiziert — oder None.
`name` ist der Hostname, den die Quelle für dieses Gerät meldet (leer, wenn
sie keinen kennt). Kein Treffer, wenn mehrere Assets die Adresse tragen
(dann identifiziert sie nichts) oder wenn beide Seiten verschiedene echte
Namen führen (dann sind es zwei Geräte).
"""
ip = (ip or "").strip()
if not ip:
return None
# limit(2): ob die Adresse eindeutig ist, entscheidet sich am zweiten Treffer.
rows = _not_pinned_by(db.query(Asset).filter(Asset.ip_address == ip),
not_pinned_by).limit(2).all()
if len(rows) != 1:
if rows:
logger.info("asset match: %s wird von mehreren Assets gefuehrt "
"— kein Identifikator", ip)
return None
asset = rows[0]
if not different_hosts(name, asset.hostname):
return asset
logger.info("asset match: %s gehoert zu '%s', die Quelle meldet dort '%s' "
"— nicht derselbe Host", ip, asset.hostname, name)
return None
def like_literal(name: str) -> str:
"""`name` als LIKE-Muster, das nur sich selbst trifft.
`_` und `%` sind in LIKE Platzhalter: "PC_LAB" traf "PC-LAB", ein anderes
Geraet.
"""
return name.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def pick_one(rows: list, ip: Optional[str], what: str) -> Optional[Asset]:
"""Das eine Asset unter gleichnamigen Kandidaten — oder keins.
Ein Kandidat: der Treffer. Mehrere: der Name identifiziert nichts, es
entscheidet die Adresse, die die Quelle meldet. Entscheidet sie nicht, gibt
es keinen Treffer ein eigenes Asset ist eine sichtbare Dublette, das
kleinste id zu nehmen hiesse Findings am falschen Host.
"""
if len(rows) <= 1:
return rows[0] if rows else None
ip = (ip or "").strip()
hits = [a for a in rows if ip and a.ip_address == ip]
if len(hits) == 1:
return hits[0]
logger.info("asset match: '%s' fuehren %d Assets, die Adresse %s entscheidet "
"nicht — kein Treffer", what, len(rows), ip or "-")
return None
def match_by_hostname(db: Session, pattern: str, *, pin_col, pin_value: Optional[str],
ip: Optional[str] = None, not_pinned_by: tuple = ()) -> Optional[Asset]:
"""Das Asset, das diesen Namen führt — außer ein anderes Gerät hat ihn auch.
Dieselbe Stufe wie oben, eine Ebene höher: nach dem stabilen Identifier
fällt jede Sync-Match-Kette auf den Hostnamen zurück, und auch der ist
kein Identifikator. Ab Werk vergebene Namen ("RackPDU", "Switch") stehen
im Bestand mehrfach, weil sie nie geändert wurden teils, weil das Gerät
es gar nicht zulässt.
`pin_col`/`pin_value` sind die Identitätsspalte DIESER Quelle und der Wert
für DIESES Gerät (netdisco_device_ip, nessus_host_uuid, vmware_uuid,
igel_unit_id). Trägt ein gleichnamiges Asset dort bereits einen anderen
Wert, gehört es einem anderen Gerät derselben Quelle der Name ist dann
ein Namensvetter, kein Treffer. Ein Asset ohne Pin bleibt matchbar: das
ist der Fall, für den die Stufe existiert (Wazuh legt an, Netdisco findet
dasselbe Gerät über den Namen).
Kennt die Quelle für dieses Gerät selbst keinen Identifier (`pin_value`
leer), gibt es nichts zu unterscheiden dann matcht der Name wie bisher.
Bleiben danach mehrere Kandidaten, entscheidet `ip` (pick_one). Ein
Muster auf ".%" ist die FQDN-Präfix-Stufe; nur dieses `%` ist Platzhalter.
`not_pinned_by`: siehe _not_pinned_by.
"""
pattern = (pattern or "").strip()
if not pattern:
return None
prefix = pattern.endswith(".%")
like = like_literal(pattern[:-1] if prefix else pattern) + ("%" if prefix else "")
q = _not_pinned_by(db.query(Asset).filter(Asset.hostname.ilike(like, escape="\\")),
not_pinned_by)
if pin_value:
q = q.filter((pin_col.is_(None)) | (pin_col == pin_value))
return pick_one(q.order_by(Asset.id).all(), ip, pattern)
# Serial numbers a device answers when it has none to answer. A placeholder is
# not an identity: matching on one would collapse every device that gives the
# same non-answer onto a single asset — the wrong-host merge, which is worse
# than the fork the serial pin exists to prevent. Netdisco passes the SNMP
# value through as it gets it; Intune's Graph payload has the same habit for
# devices that report no hardware serial.
_SERIAL_PLACEHOLDERS = {"n/a", "na", "none", "null", "unknown", "unspecified",
"not available", "not specified", "no serial", "chassis",
"system serial number", "to be filled by o.e.m.",
"default string"}
def stable_serial(serial: Optional[str]) -> Optional[str]:
"""The serial if it identifies a device, None if it is a placeholder."""
s = (serial or "").strip()
low = s.lower()
if len(s) < 4 or low in _SERIAL_PLACEHOLDERS:
return None
# All zeros, dashes, dots or colons — the other way a device says "none".
# MACs stay: Cumulus and friends answer their chassis MAC here, and that
# is a perfectly good identifier.
if not low.strip("0-.: "):
return None
return s[:64]
+93 -14
View File
@@ -7,8 +7,10 @@ previously the audit history only began with the first status change.
"""
import json
import logging
from contextlib import contextmanager
from contextvars import ContextVar
from datetime import datetime, timedelta
from typing import Iterable
from typing import Iterable, Optional
from sqlalchemy.orm import Session
@@ -71,23 +73,98 @@ def audit_new_vulnerabilities(
return written
# How far behind reality each source's inventory can be, and therefore how
# long after a close its "still vulnerable" claim is not trustworthy.
# How far behind reality each inventory can be, and therefore how long after a
# close a "still vulnerable" claim read from it is not trustworthy.
#
# Measured against how the data actually arrives, not against the scan
# schedule: Wazuh syscollector is close to live, Intune depends on the
# tenant's inventory-refresh policy, and Defender TVM's software list is the
# slowest of the three by a wide margin. Sources that read a live inventory
# themselves (wazuh_sync, app_scan, msrc, nessus) get no grace — when they say
# it is back, it is back.
# schedule: Wazuh syscollector is close to live, Defender TVM's software list
# trails by days, and Intune detectedApps depends entirely on how aggressively
# the tenant's policies push an inventory refresh — a week is not unusual.
# A live inventory (wazuh) gets no grace at all: when it says the CVE is back,
# it is back, so it is deliberately absent from this table.
_INTUNE_LAG = timedelta(days=7)
_REOPEN_GRACE = {
"defender": timedelta(days=3),
"intune": timedelta(days=1),
"m365_check": timedelta(days=1), # same Graph inventory as Intune
"intune": _INTUNE_LAG,
"m365_check": _INTUNE_LAG, # same Graph inventory as Intune
"mobile_eol": timedelta(days=1),
"android_cve": timedelta(days=1),
}
# Scanners whose verdict is only ever as fresh as the software inventory they
# were handed. They do not fetch anything themselves — the app-CVE scan reads
# whatever list `run_app_cve_scan` obtained for the asset, and the MSRC
# fixed-build pass compares against that same list. Keying their grace on the
# scanner name therefore says nothing about how old the data is (field report
# 06.09.2026: two Intune+Defender hosts, no Wazuh agent. Defender TVM saw the
# Edge update and closed a dozen CVEs at 02:10; at 03:20 the app scan pulled
# Intune's detectedApps, which still listed Edge 132.0.6199.32, and the MSRC
# pass reopened every one of them — "msrc" carried no grace because it was
# assumed to read a live inventory, which on an Intune-only host it does not).
_INVENTORY_SOURCES = {"app_scan", "msrc"}
# Which inventory the scan currently running actually read for this asset.
# Ambient rather than a parameter on purpose: a dozen scanners funnel into the
# two _upsert functions that reopen, and every one of them would otherwise have
# to thread a value through that it never looks at itself.
_current_inventory: ContextVar[Optional[str]] = ContextVar(
"reopen_inventory", default=None)
def use_inventory(name: Optional[str]) -> None:
"""Same declaration as `scanning_inventory`, for a scan loop that sets it
once per asset. Callers pass None when they are done or do not know."""
_current_inventory.set((name or "").strip().lower() or None)
def asset_inventory(asset) -> Optional[str]:
"""The inventory that owns this asset's software list and version fields.
Wazuh first: a host enrolled in both keeps syscollector as its inventory
(Intune's detectedApps is a thinner, differently-named list), which is the
same precedence `run_app_cve_scan` uses to pick who to ask.
Deliberately only those two. A Defender-only asset is never asked for a
package list at all, and the 3-day grace under "defender" describes the TVM
CVE feed, not the software inventory that follows device telemetry naming
it here would mute reopens for three days on the strength of the wrong
number, and an over-granted grace is a false negative.
"""
try:
if getattr(asset, "wazuh_agent_id", None):
return "wazuh"
if getattr(asset, "intune_device_id", None):
return "intune"
except Exception:
pass
return None
@contextmanager
def scanning_inventory(name: Optional[str]):
"""Declare whose software list the enclosed scan is deciding from.
`name` is the inventory ("wazuh", "intune", "defender"), NOT the scanner
None when the caller does not know, which leaves the old behaviour.
"""
token = _current_inventory.set((name or "").strip().lower() or None)
try:
yield
finally:
_current_inventory.reset(token)
def _reopen_grace(source: str) -> Optional[timedelta]:
"""The longest lag that applies to this claim: the source's own, plus the
inventory's when the source is only relaying one."""
keys = [(source or "").lower()]
if keys[0] in _INVENTORY_SOURCES:
inv = _current_inventory.get()
if inv:
keys.append(inv)
lags = [g for g in (_REOPEN_GRACE.get(k) for k in keys) if g]
return max(lags) if lags else None
def reopen_if_patched(db: Session, vuln, *, reason: str, source: str) -> bool:
"""Flip a patched finding back to OPEN and AUDIT the transition.
@@ -116,12 +193,14 @@ def reopen_if_patched(db: Session, vuln, *, reason: str, source: str) -> bool:
#
# A source may therefore only reopen a finding once its own lag has had
# time to pass. Below that, its claim is about a state the other source has
# already superseded.
grace = _REOPEN_GRACE.get((source or "").lower())
# already superseded. "Its own lag" is the lag of the INVENTORY the claim
# was read from, not of the scanner that read it — see _INVENTORY_SOURCES.
grace = _reopen_grace(source)
if grace and vuln.patched_at and (datetime.now() - vuln.patched_at) < grace:
logger.debug(
"reopen from %s ignored for %s — patched %s ago, within its %s lag",
source, vuln.cve_id, datetime.now() - vuln.patched_at, grace)
"reopen from %s (inventory %s) ignored for %s — patched %s ago, "
"within its %s lag", source, _current_inventory.get() or "unknown",
vuln.cve_id, datetime.now() - vuln.patched_at, grace)
return False
old_status = vuln.status
vuln.status = VulnerabilityStatus.open
+415
View File
@@ -0,0 +1,415 @@
"""Citrix Workspace app for Windows — build identity and the CVE decision.
Why this module exists
----------------------
The Windows inventory states one thing about the install: the build.
Citrix Workspace 2507 Citrix Systems, Inc. 25.7.1000.1025
It does not say Current Release (CR) or Long Term Service Release (LTSR), and
it does not say which Cumulative Update (CU). Citrix's CVE records, in turn,
state no build at all only release names, and in a different spelling on
nearly every record:
CVE-2026-78547 0 lessThan "2603.11 Current Release (CR)"
0 lessThan "2507.1 LTSR CU3"
0 lessThan "LTSR 2607"
CVE-2025-4879 "2402 LTSR" lessThan "CU2 Hotfix 1"
CVE-2024-7889 "Current Release (CR)" lessThan "2405"
Read as digits those bounds are (2603, 11), (2507, 1, 3) and (2607,), and every
25.x/26.x build sits below all of them the fully patched CU3, CR 2603.11 and
LTSR 2607 hosts included. Read literally, the three ranges also contradict each
other: "0 .. LTSR 2607" swallows 2507.1 CU3, which the same record calls fixed.
The build catalog
-----------------
Both sides meet on the BUILD, and Citrix publishes the mapping on its download
pages: one page per release, its title naming the release and a "Version:"
line naming the build ("Citrix Workspace app for Windows LTSR 2507.1 Cumulative
Update 3" / "Version: 25.7.3000.3034"). The catalog is those pairs — nothing is
derived from a numbering scheme, a row is only ever what a page says.
A page row is accepted only if its build AGREES with its title: year.month
from the release (2507.1 25.7), and the third field from the rest (CR
2603.11 11, LTSR base 2507.1 1, CU3 3000, CU2 Hotfix 1 2001). That
check is not optional: five CR pages (2302 2307.1) print a sidebar build,
22.12.0.48, as their first "Version:", and without it they would teach the
catalog that 22.12.0.48 is CR 2307.1.
Release lines
-------------
CR and LTSR never share a year.month (checked on every Windows download page:
24.2, 25.7, 26.7 are LTSR-only, there is no CR 2402/2507/2607). So the catalog
answers "which branch is this build" from its first two fields, and a build on
a year.month no page has named gets no verdict at all.
The decision then reuses vmware_release_service.is_affected, because the shape
is the same one VMware has: one fix PER LINE, several lines per CVE.
* CR is one line; a build is affected iff it is below the CR fix.
* Each LTSR (2402, 2507.1, 2607) is its own line and its CU is the update
line. CVE-2025-4879 fixes 2402 in "CU2 Hotfix 1" AND "CU3 Hotfix 1": a CU2
HF1 host is patched although CU3 base (a higher build) is not.
* A fix on another branch or another LTSR never decides: "before LTSR 2607"
says nothing about a 2402 host, and the CR fix nothing about any LTSR.
A bound that cannot be resolved to exactly one catalog build (lessThan "1",
a lessThanOrEqual, a release no page lists) makes the whole CVE undecided
"a missing verdict is recoverable, a wrong one is not".
"""
from __future__ import annotations
import html as _html
import json
import logging
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
from app.services import vmware_release_service as vmr
logger = logging.getLogger(__name__)
# The cvelistV5 registry key.
KEY = "citrix-workspace-win"
CR = "CR"
LTSR = "LTSR"
_DOWNLOADS_URL = "https://www.citrix.com/downloads/workspace-app/"
# The four sections that hold Workspace app for WINDOWS, current and legacy.
# Everything else under /workspace-app/ is another product (Mac, Linux, the
# Enterprise Browser, Desktop Lock) with its own numbering.
_WINDOWS_PAGE_RE = re.compile(
r'href="(/downloads/workspace-app/(?:windows|legacy-workspace-app-for-windows'
r'|workspace-app-for-windows-long-term-service-release'
r'|legacy-workspace-app-for-windows-ltsr)/[^"#?]+\.html)"', re.I)
_CATALOG_SETTING = "citrix_workspace_build_catalog"
_CATALOG_TTL = timedelta(days=7)
# ----------------------------------------------------------------------
# Names
# ----------------------------------------------------------------------
# "2507.1", "2402", "2603.11" — never a piece of a dotted number, or the
# "1001" in "19.12.1001" would read as a release.
_RELEASE_RE = re.compile(r"(?<![\d.])(\d{4})(?:\.(\d{1,2}))?(?![\d.])")
_CU_RE = re.compile(r"\b(?:cu|cumulative\s+update)\s*(\d+)\b", re.I)
_HF_RE = re.compile(r"\b(?:hotfix|hf)\s*(\d+)\b", re.I)
_LTSR_RE = re.compile(r"\bltsr\b|long\s+term\s+service", re.I)
_CR_RE = re.compile(r"\bcr\b|current\s+release", re.I)
_BUILD_RE = re.compile(r"^\s*(\d+)\.(\d+)\.(\d+)\.(\d+)\s*$")
@dataclass(frozen=True)
class Name:
"""What a release name says, with anything it does not say left None."""
branch: Optional[str]
release: Optional[str]
cu: Optional[int]
hotfix: Optional[int]
def parse_name(text: Optional[str]) -> Name:
s = text or ""
ltsr, cr = bool(_LTSR_RE.search(s)), bool(_CR_RE.search(s))
branch = LTSR if ltsr and not cr else CR if cr and not ltsr else None
rel = _RELEASE_RE.search(s)
cu, hf = _CU_RE.search(s), _HF_RE.search(s)
return Name(branch=branch,
release=(rel.group(0) if rel else None),
cu=(int(cu.group(1)) if cu else None),
hotfix=(int(hf.group(1)) if hf else None))
def parse_build(raw: Optional[str]) -> Optional[Tuple[int, int, int, int]]:
m = _BUILD_RE.match(raw or "")
return tuple(int(x) for x in m.groups()) if m else None
# ----------------------------------------------------------------------
# Catalog
# ----------------------------------------------------------------------
@dataclass(frozen=True)
class Entry:
branch: str
release: str
cu: int
hotfix: int
build: Tuple[int, int, int, int]
@property
def label(self) -> str:
s = f"{self.branch} {self.release}"
if self.cu:
s += f" CU{self.cu}"
if self.hotfix:
s += f" Hotfix {self.hotfix}"
return s
def entry_from_page(title: str, version: str) -> Optional[Entry]:
"""One download page (its title and "Version:") → a catalog entry, or None
when the build does not agree with the title (see the module docstring)."""
build = parse_build(version)
n = parse_name(title)
if not build or not n.release:
return None
# A download page never says "CR"; it says LTSR or nothing.
branch = LTSR if n.branch == LTSR else CR
cu, hf = n.cu or 0, n.hotfix or 0
if branch == CR and (cu or hf):
return None
yy, mm = int(n.release[:2]), int(n.release[2:4])
minor = int(n.release.split(".")[1]) if "." in n.release else 0
third = cu * 1000 + hf if cu else minor
if build[:3] != (yy, mm, third):
return None
return Entry(branch=branch, release=n.release, cu=cu, hotfix=hf, build=build)
def compile_rows(rows: Iterable[Sequence[str]]) -> List[Entry]:
out: Dict[tuple, Entry] = {}
for title, version in rows:
e = entry_from_page(title, version)
if e:
out.setdefault(e.build, e)
return list(out.values())
def branch_of(build: Tuple[int, ...], catalog: Sequence[Entry]) -> Optional[str]:
"""The branch every catalog build on this year.month belongs to, or None
when no page names the line (or, which has never happened, both do)."""
found = {e.branch for e in catalog if e.build[:2] == build[:2]}
return found.pop() if len(found) == 1 else None
def _release(branch: str, build: Tuple[int, ...], label: str) -> vmr.Release:
if branch == CR:
return vmr.Release(line=(CR,), update=0, build=build, label=label)
return vmr.Release(line=(LTSR, build[0], build[1]), update=build[2] // 1000,
build=build, label=label)
def installed_release(version: Optional[str],
catalog: Sequence[Entry]) -> Optional[vmr.Release]:
build = parse_build(version)
if not build:
return None
branch = branch_of(build, catalog)
if not branch:
return None
exact = next((e for e in catalog if e.build == build), None)
return _release(branch, build, exact.label if exact else version.strip())
def bound_release(less_than: Optional[str], version_field: Optional[str],
catalog: Sequence[Entry]) -> Optional[vmr.Release]:
"""A record's lessThan (+ its version field) → the fixing release, or None.
The version field is read for the branch ("Current Release (CR)" /
"2402 LTSR"), and for the release only when lessThan names a CU or hotfix
without one ("CU2 Hotfix 1"). A lessThan with neither CVE-2024-6286
writes "1" is not a bound and resolves to nothing.
"""
lt = (less_than or "").strip()
if not lt:
return None
build = parse_build(lt)
if build:
branch = branch_of(build, catalog)
return _release(branch, build, lt) if branch else None
n, ctx = parse_name(lt), parse_name(version_field)
release = n.release
if not release and (n.cu is not None or n.hotfix is not None):
release = ctx.release
if not release:
return None
branches = {b for b in (n.branch, ctx.branch) if b}
if len(branches) > 1:
return None
branch = branches.pop() if branches else None
hits = [e for e in catalog
if e.release == release and e.cu == (n.cu or 0)
and e.hotfix == (n.hotfix or 0) and (branch is None or e.branch == branch)]
if len({e.build for e in hits}) != 1:
return None
e = hits[0]
return _release(e.branch, e.build, f"{'.'.join(map(str, e.build))} ({e.label})")
def decide(installed: vmr.Release, entries: Sequence[dict],
catalog: Sequence[Entry]) -> Tuple[Optional[bool], Optional[str]]:
"""All index entries of ONE CVE → (affected, fix hint).
affected is None when any bound of the CVE cannot be resolved: a bound we
could not read may be the one on this host's line.
"""
bounds = []
for ent in entries:
if not ent.get("lt"):
return None, None # lessThanOrEqual only: no fix build named
b = bound_release(ent.get("lt"), ent.get("ver"), catalog)
if b is None:
return None, None
bounds.append(b)
if not bounds:
return None, None
if not vmr.is_affected(installed, bounds):
return False, None
return True, vmr.fix_hint(installed, bounds)
# ----------------------------------------------------------------------
# Seed (Citrix download pages, 2026-09-14) and refresh
# ----------------------------------------------------------------------
SEED_PAGES: List[Tuple[str, str]] = [
('Citrix Workspace app 1912 LTSR for Windows', '19.12.0.119'),
('Citrix Workspace app for Windows LTSR 2203.1', '22.3.1.41'),
('Citrix Workspace app 22.03.5000 for Windows, LTSR 2203.1 Cumulative Update 5', '22.03.5000.5107'),
('Citrix Workspace app 22.03.6002 for Windows, LTSR 2203.1 Cumulative Update 6 Hotfix 2', '22.03.6002.6116'),
('Citrix Workspace app 2209 for Windows', '22.9.0.28'),
('Citrix Workspace app 2210 for Windows', '22.10.0.21'),
('Citrix Workspace app 2210.5 for Windows', '22.10.5.14'),
('Citrix Workspace app 2212 for Windows', '22.12.0.48'),
('Citrix Workspace app 2309 for Windows', '23.9.0.99'),
('Citrix Workspace app 2309.1 for Windows', '23.9.1.104'),
('Citrix Workspace app 2311.1 for Windows', '23.11.1.140'),
('Citrix Workspace app LTSR 2402 for Windows', '24.2.0.172'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 1', '24.2.1000.1016'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 1 Hotfix 1 - 24.02.1001', '24.2.1001.2'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 1 Hotfix 3 - 24.02.1003', '24.2.1003.3'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 2 Hotfix 1', '24.2.2001.3'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 3 Hotfix 1', '24.2.3001.9'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 4 Hotfix 1', '24.2.4001.1'),
('Citrix Workspace app 2403 for Windows', '24.3.0.93'),
('Citrix Workspace app 2403.1 for Windows', '24.3.1.97'),
('Citrix Workspace app 2405 for Windows', '24.5.0.131'),
('Citrix Workspace app 2405.10 for Windows', '24.5.10.29'),
('Citrix Workspace app 2405.11 for Windows', '24.5.11.31'),
('Citrix Workspace app 2405.12 for Windows', '24.5.12.42'),
('Citrix Workspace app 2409 for Windows', '24.9.0.201'),
('Citrix Workspace app 2409.1 for Windows', '24.9.1.207'),
('Citrix Workspace app 2409.10 for Windows', '24.9.10.28'),
('Citrix Workspace app 2503.1 for Windows', '25.3.1.194'),
('Citrix Workspace app 2503.2 for Windows', '25.3.2.196'),
('Citrix Workspace app 2503.10 for Windows', '25.3.10.69'),
('Citrix Workspace app LTSR 2507.1 for Windows', '25.7.1.9'),
('Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 1', '25.7.1000.1025'),
('Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 2', '25.7.2000.2020'),
('Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 3', '25.7.3000.3034'),
('Citrix Workspace app 2508 for Windows', '25.8.0.71'),
('Citrix Workspace app 2508.10 for Windows', '25.8.10.36'),
('Citrix Workspace app 2511 for Windows', '25.11.0.200'),
('Citrix Workspace app 2511.1 for Windows', '25.11.1.209'),
('Citrix Workspace app 2511.10 for Windows', '25.11.10.50'),
('Citrix Workspace app 2603 for Windows', '26.3.0.188'),
('Citrix Workspace app 2603.1 for Windows', '26.3.1.194'),
('Citrix Workspace app 2603.10 for Windows', '26.3.10.69'),
('Citrix Workspace app 2603.11 for Windows', '26.3.11.10'),
('Citrix Workspace app for Windows LTSR 2607', '26.7.0.269'),
]
_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S | re.I)
_PAGE_VERSION_RE = re.compile(r"Version\s*:\s*([\d.]+)")
_TAG_RE = re.compile(r"<[^>]+>")
def parse_page(page_html: str) -> Optional[Tuple[str, str]]:
"""A download page → (title, first "Version:" after the title)."""
m = _H1_RE.search(page_html or "")
if not m:
return None
title = _html.unescape(_TAG_RE.sub("", m.group(1))).strip()
v = _PAGE_VERSION_RE.search(page_html, m.end())
return (title, v.group(1).rstrip(".")) if v else None
def _fetch_pages() -> List[Tuple[str, str]]:
import httpx
rows: List[Tuple[str, str]] = []
with httpx.Client(timeout=30.0, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 (TrueVuln)"}) as c:
r = c.get(_DOWNLOADS_URL)
r.raise_for_status()
links = sorted(set(_WINDOWS_PAGE_RE.findall(r.text)))
for path in links:
try:
p = c.get("https://www.citrix.com" + path)
p.raise_for_status()
except Exception as e:
logger.debug("citrix: %s fetch failed: %s", path, e)
continue
row = parse_page(p.text)
if row:
rows.append(row)
valid = len(compile_rows(rows))
if valid < 20:
raise ValueError(f"only {valid} valid build rows from {len(links)} pages — layout changed?")
return rows
def refresh_catalog(db) -> List[Entry]:
"""Re-read the download pages; merged over cache and seed, never replacing
them, so an outage or layout change can only fail to ADD builds."""
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _CATALOG_SETTING).first()
rows = list(SEED_PAGES) + _cached_rows(row)
try:
fetched = _fetch_pages()
rows += fetched
logger.info("citrix: Workspace app build catalog refreshed (%d pages)", len(fetched))
except Exception as e:
logger.warning("citrix: build catalog refresh failed (%s) — keeping cached/seed", e)
rows = sorted({(t, v) for t, v in rows})
payload = json.dumps({"fetched_at": datetime.now().isoformat(), "rows": rows})
if row:
row.value = payload
else:
db.add(Setting(key=_CATALOG_SETTING, value=payload,
description="Citrix Workspace app for Windows: release → build (download pages)"))
db.commit()
_MEMO.clear()
return compile_rows(rows)
def _cached_rows(row) -> List[Tuple[str, str]]:
if not row or not row.value:
return []
try:
return [tuple(r) for r in json.loads(row.value).get("rows") or []]
except Exception:
return []
_MEMO: Dict[str, List[Entry]] = {}
def load_catalog(db) -> List[Entry]:
"""Seed plus whatever the last refresh stored, at any age: an old catalog
only lacks the newest releases, and those get no verdict until it learns
them."""
if "catalog" not in _MEMO:
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _CATALOG_SETTING).first()
_MEMO["catalog"] = compile_rows(list(SEED_PAGES) + _cached_rows(row))
return _MEMO["catalog"]
def build_index(db) -> None:
"""Nightly hook (scheduler): refresh the catalog once it is a week old."""
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _CATALOG_SETTING).first()
try:
fresh = row and datetime.now() - datetime.fromisoformat(
json.loads(row.value)["fetched_at"]) <= _CATALOG_TTL
except Exception:
fresh = False
if not fresh:
refresh_catalog(db)
+17 -2
View File
@@ -180,8 +180,13 @@ def refresh_asset_compliance(
return stats
def refresh_all_compliance(db: Session) -> Dict[str, Any]:
"""Loop over every Wazuh-linked asset and refresh its SCA results."""
def refresh_all_compliance(db: Session, trigger: str = "manual") -> Dict[str, Any]:
"""Loop over every Wazuh-linked asset and refresh its SCA results.
A refused login is recorded as a failed Wazuh run (mail, cooldown)
otherwise it is one "wazuh API:" error per asset and a job that
completed."""
from app.services.sync_run_service import probe_wazuh_api, report_source_failure, source_failure
client = _load_wazuh_client(db)
if client is None:
return {
@@ -189,6 +194,13 @@ def refresh_all_compliance(db: Session) -> Dict[str, Any]:
"policies_synced": 0,
"errors": ["wazuh_config not set"],
}
err = probe_wazuh_api(client, phase="sca-refresh", trigger=trigger)
if err:
try:
client.close()
except Exception:
pass
return {"assets_synced": 0, "policies_synced": 0, "errors": [f"wazuh API: {err}"]}
# No status filter — Wazuh keeps SCA results for disconnected agents
# in the indexer too, so we let the API decide what's available.
@@ -223,6 +235,9 @@ def refresh_all_compliance(db: Session) -> Dict[str, Any]:
"compliance: refreshed %d assets, %d policy results, %d errors",
overall["assets_synced"], overall["policies_synced"], len(overall["errors"]),
)
hit = source_failure(e for e in overall["errors"] if str(e).startswith("wazuh API:"))
if hit:
report_source_failure("wazuh", trigger, "sca-refresh", hit)
return overall
+359
View File
@@ -0,0 +1,359 @@
"""
Container image scan import Trivy / Grype JSON pushed from a Docker host.
One asset per image (normalised `repo:tag`), source CONTAINER. Every container
running that image shares the asset, so a base-image CVE counts once per image,
not once per container. Findings merge on (cve_id, asset_id) like every other
scanner; `sources` carries "trivy" and/or "grype".
Noise control, in order of effect:
1. Baseline: the first report of an image REPOSITORY is imported with
`baseline` set and sends no new-finding mail. Later reports a new tag of
the same repository included, so an upgrade that brings a CVE in does
alert notify for findings new on that image. KEV alerts need nothing
here: kev_alert_service fires when a catalog flips on an open finding.
2. only_fixed (default on): no fix published not imported.
3. min_severity (default high): below it not imported.
Neither filter applies to a CVE on a known-exploited list (CISA KEV, ENISA
EUVD, VulnCheck KEV kev_service): actively exploited is never noise, fix
or no fix, whatever the scanner's label. Enrichment then sets kev_listed,
which drives the priority score and the KEV alert mail.
4. Alias resolution: a GHSA / distro advisory is stored under its CVE, so
Trivy and Grype merge instead of counting the same bug twice. Only the ids
the report itself names are used no lookup, no guessing.
Accepted risks: mark the finding false-positive / accepted-risk in the UI;
the import neither reopens nor closes those. Not .trivyignore: an ignored CVE
drops out of the report and would be closed as patched.
Retraction follows the cross-source contract: a scanner drops only its own
source, and only for CVEs absent from its WHOLE report a CVE the ingest
filter hid is still there and must not close. A report with no findings at
all retracts nothing (a scanner that did not recognise the distro looks the
same as a clean image), as in nessus_sync. An image that is no longer
pushed goes INACTIVE through the time-based asset reconcile.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from typing import Dict, List, Optional, Tuple
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetSource, AssetStatus
from app.models.vulnerability import (
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStatus,
)
logger = logging.getLogger(__name__)
CVE_ID = re.compile(r"^CVE-\d{4}-\d{4,}$", re.I)
_RANK = {"none": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
_LABEL = {"negligible": "low", "moderate": "medium", "important": "high", "unknown": "none"}
def normalise_image(ref: str) -> str:
"""`docker.io/library/nginx` and `nginx:latest` are the same image."""
ref = (ref or "").strip()
for prefix in ("docker.io/library/", "index.docker.io/library/", "docker.io/"):
if ref.startswith(prefix):
ref = ref[len(prefix):]
break
if "@" not in ref and ":" not in ref.rsplit("/", 1)[-1]:
ref += ":latest"
return ref
def _repository(image: str) -> str:
"""`nginx:1.25` → `nginx`; `registry:5000/app@sha256:…` → `registry:5000/app`."""
ref = image.split("@", 1)[0]
head, _, last = ref.rpartition("/")
return f"{head}/{last.split(':', 1)[0]}" if head else last.split(":", 1)[0]
def severity_band(label: Optional[str], cvss: Optional[float]) -> str:
sev = str(label or "").strip().lower()
sev = _LABEL.get(sev, sev)
if sev not in _RANK:
sev = "none"
if sev == "none" and cvss is not None:
sev = ("critical" if cvss >= 9 else "high" if cvss >= 7
else "medium" if cvss >= 4 else "low" if cvss > 0 else "none")
return sev
def _cve_ids(primary: str, aliases: List[str]) -> List[str]:
"""The CVE(s) a finding is about. A CVE id stays itself; an advisory that
names CVEs becomes those CVEs (one advisory can cover several); anything
else keeps its own id."""
if CVE_ID.match(primary or ""):
return [primary.upper()]
cves = sorted({a.upper() for a in aliases if CVE_ID.match(a or "")})
return cves or [primary]
def _grype_score(cvss: list) -> Optional[float]:
entries = sorted(cvss or [], key=lambda c: c.get("type") != "Primary")
for c in entries:
if str(c.get("version", "")).startswith("3"):
score = (c.get("metrics") or {}).get("baseScore")
if score is not None:
return float(score)
return None
def parse_report(report: dict) -> Tuple[str, str, Optional[str], Optional[str], List[dict]]:
"""→ (scanner, image, digest, os, entries). Each entry is one package hit:
{ids, severity, cvss, fixed, package, version, fixed_version, title, description, url}.
Raises ValueError for anything that is not an image report."""
if not isinstance(report, dict):
raise ValueError("report must be a JSON object")
if "SchemaVersion" in report or "ArtifactName" in report:
if report.get("ArtifactType") != "container_image":
raise ValueError(f"Trivy report is not an image scan (ArtifactType={report.get('ArtifactType')!r})")
meta = report.get("Metadata") or {}
os_ = meta.get("OS") or {}
entries = []
for result in report.get("Results") or []:
for v in result.get("Vulnerabilities") or []:
scores = v.get("CVSS") or {}
score = next((s.get("V3Score") for s in
[scores.get("nvd") or {}, *scores.values()] if s.get("V3Score") is not None), None)
entries.append({
"ids": _cve_ids(v.get("VulnerabilityID", ""), v.get("VendorIDs") or []),
"severity": severity_band(v.get("Severity"), score),
"cvss": score,
"fixed": bool(v.get("FixedVersion")),
"package": v.get("PkgName"),
"version": v.get("InstalledVersion"),
"fixed_version": v.get("FixedVersion"),
"title": v.get("Title"),
"description": v.get("Description"),
"url": v.get("PrimaryURL"),
})
return ("trivy", normalise_image(report.get("ArtifactName")),
next(iter(meta.get("RepoDigests") or []), None),
" ".join(filter(None, [os_.get("Family"), os_.get("Name")])) or None,
entries)
if "matches" in report and "source" in report:
source = report.get("source") or {}
if source.get("type") != "image":
raise ValueError(f"Grype report is not an image scan (source.type={source.get('type')!r})")
target = source.get("target") or {}
distro = report.get("distro") or {}
entries = []
for m in report.get("matches") or []:
v = m.get("vulnerability") or {}
related = m.get("relatedVulnerabilities") or []
fix = v.get("fix") or {}
ids = _cve_ids(v.get("id", ""), [r.get("id") for r in related])
for cve in ids:
rel = next((r for r in related if (r.get("id") or "").upper() == cve), {})
score = _grype_score(rel.get("cvss")) or _grype_score(v.get("cvss"))
entries.append({
"ids": [cve],
"severity": severity_band(v.get("severity"), score),
"cvss": score,
"fixed": fix.get("state") == "fixed" and bool(fix.get("versions")),
"package": (m.get("artifact") or {}).get("name"),
"version": (m.get("artifact") or {}).get("version"),
"fixed_version": ", ".join(fix.get("versions") or []) or None,
"title": None,
"description": rel.get("description") or v.get("description"),
"url": v.get("dataSource"),
})
return ("grype", normalise_image(target.get("userInput")),
next(iter(target.get("repoDigests") or []), None),
" ".join(filter(None, [distro.get("name"), distro.get("version")])) or None,
entries)
raise ValueError("not a Trivy (--format json) or Grype (-o json) report")
def _exploited(db: Session) -> set:
"""CVE ids on any KEV catalog. Empty when the catalogs are unavailable —
the filter then applies as configured, and the failure is logged."""
from app.services.kev_service import get_kev_catalog
try:
return set(get_kev_catalog(db))
except Exception as e:
logger.warning("container import: KEV catalog unavailable, filtering without it: %s", e)
return set()
def _enrich(db: Session, vulns: list) -> None:
from app.services.enrichment_service import enrich_vulnerabilities
enrich_vulnerabilities(db, vulns, use_nvd_dates=False)
def _notify(db: Session, vulns: list) -> dict:
from app.services.email_service import dispatch_new_vuln_notifications
return dispatch_new_vuln_notifications(db, vulns)
def ingest_report(db: Session, report: dict, *, min_severity: str = "high",
only_fixed: bool = True) -> dict:
"""Import one Trivy / Grype image report. Commits. Raises ValueError on a bad report."""
scanner, image, digest, os_name, entries = parse_report(report)
return ingest_image(db, scanner, image, entries, digest=digest, os_name=os_name,
min_severity=min_severity, only_fixed=only_fixed)
def ingest_image(db: Session, scanner: str, image: str, entries: List[dict], *,
digest: Optional[str] = None, os_name: Optional[str] = None,
description: Optional[str] = None,
min_severity: str = "high", only_fixed: bool = True) -> dict:
"""Import every finding one source reports for one image — `entries` in the
shape parse_report returns, and ALL of them: the unfiltered set is what
retraction compares against. `description` replaces the digest line (the
Dockhand pull says where the image runs). Commits."""
from app.routers.vulnerabilities import log_vulnerability_change
from app.services.asset_lifecycle import apply_status, created
from app.services.asset_matching import like_literal
from app.services.audit_events import (
audit_new_vulnerabilities, record_affected_package, reopen_if_patched,
)
floor = _RANK.get(min_severity.lower())
if floor is None:
raise ValueError(f"min_severity must be one of {list(_RANK)}")
if not image:
raise ValueError("report names no image")
stats = {"scanner": scanner, "image": image, "baseline": False, "findings_reported": 0,
"filtered_severity": 0, "filtered_unfixed": 0, "kev_kept": 0, "vulns_created": 0,
"vulns_merged": 0, "vulns_closed": 0, "notifications": {}}
now = datetime.now()
asset = (db.query(Asset)
.filter(Asset.source == AssetSource.CONTAINER, Asset.hostname == image)
.first())
if asset is None:
repo = like_literal(_repository(image))
stats["baseline"] = not (db.query(Asset.id)
.filter(Asset.source == AssetSource.CONTAINER,
Asset.hostname.like(f"{repo}:%", escape="\\")
| Asset.hostname.like(f"{repo}@%", escape="\\"))
.first())
asset = Asset(hostname=image, source=AssetSource.CONTAINER, status=AssetStatus.ACTIVE)
db.add(asset)
db.flush()
created(db, asset, scanner)
else:
apply_status(db, asset, AssetStatus.ACTIVE, f"{scanner} image report received")
asset.operating_system = os_name or asset.operating_system
if description or digest:
asset.description = description or f"Container image, digest {digest}"
asset.last_scan = asset.last_seen = now
asset.last_seen_source = scanner
# Everything the scanner reports, filtered or not — the retraction set.
reported = {cve for e in entries for cve in e["ids"]}
stats["findings_reported"] = len(reported)
exploited = _exploited(db)
kept: Dict[str, dict] = {}
for e in entries:
if any(cve in exploited for cve in e["ids"]):
stats["kev_kept"] += int(_RANK[e["severity"]] < floor or (only_fixed and not e["fixed"]))
elif _RANK[e["severity"]] < floor:
stats["filtered_severity"] += 1
continue
elif only_fixed and not e["fixed"]:
stats["filtered_unfixed"] += 1
continue
for cve in e["ids"]:
agg = kept.setdefault(cve, {**e, "packages": []})
if _RANK[e["severity"]] > _RANK[agg["severity"]]:
agg["severity"] = e["severity"]
if e["cvss"] is not None and (agg["cvss"] is None or e["cvss"] > agg["cvss"]):
agg["cvss"] = e["cvss"]
agg["packages"].append(e)
new_vulns = []
for cve, f in kept.items():
pkg = f["packages"][0]
vuln = (db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve[:50], Vulnerability.asset_id == asset.id)
.first())
if vuln is not None:
vuln.add_source(scanner)
if vuln.cvss_score is None and f["cvss"] is not None:
vuln.cvss_score = f["cvss"]
vuln.fixed_version = vuln.fixed_version or pkg["fixed_version"]
if f["url"]:
vuln.add_reference(f["url"])
reopen_if_patched(db, vuln, reason=f"{scanner} reports this CVE in {image} again",
source=scanner)
stats["vulns_merged"] += 1
else:
vuln = Vulnerability(
cve_id=cve[:50], asset_id=asset.id, cvss_score=f["cvss"],
severity=VulnerabilitySeverity(f["severity"]), status=VulnerabilityStatus.open,
title=(f["title"] or None) and f["title"][:500], description=f["description"],
package_name=(pkg["package"] or "")[:255] or None,
package_version=(pkg["version"] or "")[:100] or None,
fixed_version=(pkg["fixed_version"] or "")[:100] or None,
references=json.dumps([f["url"]]) if f["url"] else None,
detected_at=now, sources=json.dumps([scanner]), first_detected_by=scanner,
baseline=stats["baseline"],
)
try:
with db.begin_nested():
db.add(vuln)
db.flush()
except IntegrityError:
logger.debug("container import: duplicate insert race for %s on %s", cve, image)
continue
try:
from app.services.vuln_override_service import apply_canonical_from_siblings
apply_canonical_from_siblings(db, vuln)
except Exception as e:
logger.warning("canonical-inherit on container vuln %s failed: %s", cve, e)
new_vulns.append(vuln)
stats["vulns_created"] += 1
for p in f["packages"]:
record_affected_package(db, vuln, name=p["package"], version=p["version"],
fixed_version=p["fixed_version"], source=scanner)
vuln.refresh_scores()
stale = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains(f'"{scanner}"'))
.all()) if reported else []
stats["retraction_skipped"] = not reported
for v in stale:
if v.cve_id in reported:
continue
v.remove_source(scanner)
if not v.source_list:
v.status = VulnerabilityStatus.patched
v.patched_at = now
stats["vulns_closed"] += 1
log_vulnerability_change(
db, None, v.id, VulnerabilityStatus.open, v.status,
reason=f"{scanner} no longer reports this CVE in {image}",
cve_id=v.cve_id, source=scanner, hostname=image,
)
db.commit()
if new_vulns:
audit_new_vulnerabilities(db, [v.id for v in new_vulns], source=scanner)
db.commit()
try:
_enrich(db, new_vulns)
except Exception as e:
logger.warning("container import: enrichment failed (non-fatal): %s", e)
if not stats["baseline"]:
stats["notifications"] = _notify(db, new_vulns)
logger.info("container import %s", stats)
return stats
File diff suppressed because it is too large Load Diff
+41 -11
View File
@@ -15,6 +15,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset
from app.services.asset_matching import like_literal, pick_one
logger = logging.getLogger(__name__)
@@ -91,13 +92,15 @@ def _match_asset(db: Session, machine: dict):
_pin(a)
return a
short = dns.split(".")[0] if dns else ""
for cand in [c for c in (dns, short) if c]:
a = db.query(Asset).filter(Asset.hostname.ilike(cand)).first()
if a:
_pin(a)
return a
if short:
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
# Not guarded on the machine id (see README.DEV, "What identifies a
# device"), but not a guess either: Intune keeps same-named devices as
# separate assets, and when the name is carried by several the machine's
# address decides — or nothing matches.
ip = machine.get("lastIpAddress")
for like in [like_literal(c) for c in (dns, short) if c] + \
([like_literal(short) + ".%"] if short else []):
a = pick_one(db.query(Asset).filter(Asset.hostname.ilike(like, escape="\\"))
.order_by(Asset.id).all(), ip, dns)
if a:
_pin(a)
return a
@@ -105,7 +108,7 @@ def _match_asset(db: Session, machine: dict):
def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optional[str] = None,
vendor: Optional[str] = None) -> None:
vendor: Optional[str] = None, os_fallback: bool = True) -> None:
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
cve_id = (vuln.get("id") or "").strip().upper()
if not cve_id.startswith("CVE-"):
@@ -127,11 +130,28 @@ def _upsert_cve(db: Session, asset, vuln: dict, new_ids: list, software: Optiona
# missing entirely — those rows showed an empty package and an empty
# "Installed", which reads as "we know nothing" when the asset record has
# had the OS and its version all along (seen: CVE-2026-64726 on iPhones).
label = software or (asset.operating_system or None)
installed = asset.os_version if not software else None
# Only for a device the software export does not cover at all, though: on a
# covered one a missing label means the export has not caught up with a CVE
# published that day, and the fallback labelled Chrome and Edge CVEs
# "Microsoft Windows" with the Windows build (seen: CVE-2026-87474).
label = software or (asset.operating_system if os_fallback else None) or None
installed = asset.os_version if not software and os_fallback else None
if existing:
existing.add_source(SOURCE_NAME)
# Replace that OS placeholder with the export's own label once it has
# one — fill-only kept it for good. Whatever Defender names, OS or not
# ("microsoft windows_defender 1.1.26070.7"), is more exact than the
# asset's OS string. Only Defender's own placeholder, and not while
# MSRC claims the row: its OS reconcile finds a finding by the asset's
# OS string, so renaming it would leave MSRC no way to close it.
if (software and existing.first_detected_by == SOURCE_NAME
and asset.operating_system
and existing.package_name == asset.operating_system[:255]
and "msrc" not in existing.source_list):
existing.package_name = software[:255]
existing.package_version = None
existing.package_vendor = vendor[:255] if vendor else None
# Fill the affected-software/package column if it was empty.
if label and not existing.package_name:
existing.package_name = label[:255]
@@ -230,10 +250,13 @@ def run_defender_sync(db: Session) -> dict:
# (machineId, CVE) → affected software string. One tenant-wide export
# call; the per-machine /vulnerabilities endpoint omits software.
sw_map: dict = {}
covered: set = set() # machines the export reports on at all
try:
for r in client.get_software_vulnerabilities_by_machine():
mid = (r.get("deviceId") or r.get("machineId") or "").strip()
cve = (r.get("cveId") or "").strip().upper()
if mid:
covered.add(mid)
if not mid or not cve:
continue
vendor = (r.get("softwareVendor") or r.get("productVendor") or "").strip()
@@ -278,7 +301,10 @@ def run_defender_sync(db: Session) -> dict:
seen_cves.add(cve)
sw = sw_map.get((m.get("id", ""), cve)) or {}
_upsert_cve(db, asset, v, new_ids,
software=sw.get("label"), vendor=sw.get("vendor"))
software=sw.get("label"), vendor=sw.get("vendor"),
# An empty export (outage, no licence) covers no
# device, so it says nothing about any of them.
os_fallback=bool(covered) and m.get("id", "") not in covered)
stats["cve_rows"] += 1
# Collect, resolve later. Several Defender machines can map to ONE
# asset — a re-imaged or dual-registered device keeps its old
@@ -301,6 +327,10 @@ def run_defender_sync(db: Session) -> dict:
pkgs = inv_map.get(m.get("id", ""))
if pkgs:
from app.services.intune_service import _run_app_inventory
# No `inventory=`: this list follows the device's telemetry
# (see above), so it is not one of the lagging ones — and the
# 3-day grace on "defender" belongs to the TVM CVE feed, not
# here. None also clears whatever the enclosing loop declared.
stats["app_findings"] = stats.get("app_findings", 0) + \
_run_app_inventory(db, asset, pkgs)
except Exception as e:
+303
View File
@@ -0,0 +1,303 @@
"""
Dockhand container image findings.
Pulls every managed environment's vulnerability export and feeds it, image by
image, through container_scan_service.ingest_image the same assets
(CONTAINER, one per normalised repo:tag), baseline, filter and retraction as
the Trivy / Grype push, under the source name "dockhand".
What the export does not carry, and what that costs:
* no CVSS severity is Dockhand's label until enrichment / the override
cascade fills the score;
* no aliases Grype reports GHSA ids unless Dockhand runs it with
`--by-cve` (Settings General Default Grype arguments:
`-o json --by-cve -v {image}`), and a GHSA id does not merge with the CVE
another source reports;
* no clean images an image without findings is not in the export, so it
cannot be told from one Dockhand never scanned. Such an image is left
alone; if it is gone from the host it goes INACTIVE via the time-based
asset reconcile, but a rebuild that fixed EVERYTHING keeps its old findings
until then. A known exception to "every detection closes itself";
* the latest scan per image AND scanner after switching Dockhand from
Grype to Trivy, the last Grype scan of an image still on the host stays in
the export, and what it found cannot close until that image is gone.
Freshness: Dockhand scans only on pull, update or by hand, so the export ages
with the image. With `scan_nightly` set, the nightly sync first has Dockhand
rescan every environment (scan-all). A failed rescan does not abort: the
export then still holds the previous scan, which is what a sync without the
rescan would import anyway.
Retraction runs per image over the union of ALL environments: the same image
in two environments is one asset, and env A must not close what env B still
reports. For the same reason one failing environment aborts the whole sync.
Which hosts run the image, as which containers, is in the asset description,
and where the Docker host's hostname names exactly one host asset — in
container_placements, which links the image to that host.
"""
from __future__ import annotations
import ipaddress
import json
import logging
import re
from typing import Dict, List, Optional
from sqlalchemy.orm import Session
from app.integrations.dockhand_client import DockhandClient, DockhandError
from app.models.asset import Asset, AssetSource, AssetStatus, ContainerPlacement
from app.services import container_scan_service as cs
from app.services.asset_matching import different_hosts, like_literal, pick_one
logger = logging.getLogger(__name__)
SETTING_KEY = "dockhand_config"
SOURCE_NAME = "dockhand"
_SYNC_ADVISORY_LOCK_KEY = 0x54560105 # "TV" + 05
_BARE_DIGEST = re.compile(r"^(sha256:)?[0-9a-f]{64}$")
def load_dockhand_config(db: Session) -> Optional[dict]:
"""Decrypt + parse dockhand_config, or None when not configured (no base_url).
The token is optional: a Dockhand with auth disabled answers without one."""
from app.auth.setting_crypto import read_setting_value
raw = read_setting_value(db, SETTING_KEY)
if not raw:
return None
try:
cfg = json.loads(raw)
except json.JSONDecodeError:
logger.warning("dockhand_config is not valid JSON")
return None
return cfg if cfg.get("base_url") else None
def _build_client(cfg: dict) -> DockhandClient:
return DockhandClient(cfg["base_url"], token=cfg.get("token") or "",
verify_ssl=cfg.get("verify_ssl", True))
def test_connection(cfg: dict) -> dict:
"""Token + URL probe: the environment list only, the exports are too slow
for a synchronous request."""
client = _build_client(cfg)
try:
return {"ok": True, "environments": len(client.environments())}
except Exception as e:
return {"ok": False, "error": str(e)}
finally:
client.close()
def _entry(f: dict) -> dict:
"""One Dockhand finding in container_scan_service's entry shape."""
cve = str(f.get("cve") or "").strip()
return {
"ids": [cve.upper()] if cs.CVE_ID.match(cve) else [cve],
"severity": cs.severity_band(f.get("severity"), None),
"cvss": None,
"fixed": bool(f.get("fixedVersion")),
"package": f.get("package"),
"version": f.get("installedVersion"),
"fixed_version": f.get("fixedVersion") or None,
"title": None,
"description": f.get("description"),
"url": f.get("link"),
}
def _env_label(env: dict, hostname: str = "") -> str:
"""`prod (host hhdocker01, hawser-edge, 203.0.113.10)` — the name the
operator gave the environment in Dockhand, the Docker host's own name, and
how it is reached. Dockhand exposes no Docker daemon id (and Podman has
none), so the hostname is what names the machine."""
if hostname in ("", "unknown", env.get("name")):
hostname = ""
extra = [x for x in (hostname and f"host {hostname}", env.get("connectionType"),
env.get("host"), env.get("publicIp")) if x]
name = str(env.get("name") or env["id"])
return f"{name} ({', '.join(map(str, extra))})" if extra else name
def _short_id(value: str) -> str:
"""The 12-character id `docker ps` / `docker images` show."""
return str(value or "").removeprefix("sha256:")[:12]
def _where(runs: Dict[str, dict]) -> str:
"""Asset description: on which Docker host the image runs, and as which
containers. One asset per image (see module docstring) this is what tells
the hosts apart. Container ids change with every recreate, so they are
shown, never matched on; the image id tells a host still on the old build
from one already updated."""
lines = ["Container image, via Dockhand:"]
for env, run in sorted(runs.items()):
parts = ["image " + ", ".join(sorted(run["image_ids"])) if run["image_ids"] else ""]
if run["containers"]:
parts.append("containers " + ", ".join(
f"{name} ({cid})" for cid, name in sorted(run["containers"].items(), key=lambda c: c[1])))
if run["stacks"]:
parts.append("stack " + ", ".join(sorted(run["stacks"])))
lines.append(f"- {env}: " + "; ".join(p for p in parts if p))
return "\n".join(lines)
def _is_ip(value: str) -> bool:
try:
ipaddress.ip_address(value)
return True
except ValueError:
return False
def _host_asset(db: Session, hostname: str, ip: Optional[str]) -> Optional[Asset]:
"""The host asset (Wazuh, Nessus, vCenter …) of the Docker host that calls
itself `hostname`, or None. The exact name first, then FQDN vs short form
(docker-01 docker-01.corp.example). Two candidates and the address does
not decide no link: an image on the wrong host is worse than none."""
name = hostname.strip().lower()
if name in ("", "unknown") or _is_ip(name):
return None
q = db.query(Asset).filter(Asset.source != AssetSource.CONTAINER,
Asset.status != AssetStatus.DECOMMISSIONED)
rows = q.filter(Asset.hostname.ilike(like_literal(name), escape="\\")).all()
if not rows:
short = like_literal(name.split(".")[0])
rows = [a for a in q.filter(Asset.hostname.ilike(short, escape="\\")
| Asset.hostname.ilike(short + ".%", escape="\\"))
if not different_hosts(a.hostname, name)]
return pick_one(sorted(rows, key=lambda a: a.id), ip, name)
def _place(db: Session, placed: Dict[str, Dict[int, set]], ingested: List[str]) -> int:
"""Rewrite container_placements from this sync. Only images that imported
get a row; the whole table is Dockhand's, so what is not rewritten is gone
from every host. Returns the number of links."""
db.query(ContainerPlacement).delete()
n = 0
for image in ingested:
asset_id = (db.query(Asset.id)
.filter(Asset.source == AssetSource.CONTAINER, Asset.hostname == image)
.scalar())
for host_id, names in sorted(placed.get(image, {}).items()):
db.add(ContainerPlacement(image_asset_id=asset_id, host_asset_id=host_id,
containers=", ".join(sorted(names)) or None))
n += 1
db.commit()
return n
def run_dockhand_sync(db: Session, rescan: bool = False) -> dict:
"""`rescan` (nightly job only) rescans each environment first when the
config enables `scan_nightly` too slow for Sync now."""
cfg = load_dockhand_config(db)
if not cfg:
raise RuntimeError("Dockhand is not configured (settings.dockhand_config missing/incomplete).")
from app.database import advisory_lock
with advisory_lock(_SYNC_ADVISORY_LOCK_KEY) as got:
if not got:
logger.warning("Dockhand sync skipped — another Dockhand sync holds the lock")
return {"skipped": "another sync already running"}
return _run_dockhand_sync_locked(db, cfg, rescan=rescan)
def _rescan(client: DockhandClient, env: dict, stats: dict) -> None:
name = env.get("name") or env["id"]
try:
got = client.scan_all(env["id"])
except DockhandError as e:
stats["errors"].append(f"rescan {name}: {e}")
return
if "total" not in got:
stats["errors"].append(f"rescan {name}: {got.get('error') or got}")
return
stats["images_rescanned"] += got.get("scanned") or 0
if got.get("failed"):
stats["errors"].append(f"rescan {name}: {got['failed']} of {got['total']} images failed")
def _run_dockhand_sync_locked(db: Session, cfg: dict, rescan: bool = False) -> dict:
min_severity = cfg.get("min_severity") or "high"
only_fixed = cfg.get("only_fixed", True)
stats = {"environments": 0, "images": 0, "untagged_skipped": 0, "no_id_skipped": 0,
"baseline_images": 0,
"filtered_severity": 0, "filtered_unfixed": 0, "kev_kept": 0, "vulns_created": 0,
"vulns_merged": 0, "vulns_closed": 0, "images_rescanned": 0, "images_on_hosts": 0,
"errors": []}
rescan = rescan and bool(cfg.get("scan_nightly"))
# Fetch everything first: a failing environment raises here, before a
# single row is written (see module docstring).
by_image: Dict[str, List[dict]] = {}
runs: Dict[str, Dict[str, dict]] = {} # image → env label → where it runs
placed: Dict[str, Dict[int, set]] = {} # image → host asset id → container names
client = _build_client(cfg)
try:
envs = client.environments()
stats["environments"] = len(envs)
for env in envs:
if rescan:
_rescan(client, env, stats)
findings = client.findings(env["id"])
# Dockhand drops images no longer on the host from the export —
# but only while it can reach the host. Unreachable, it serves
# every scan it ever stored, and /api/images answers []. Importing
# that would revive deleted images and keep their findings open.
if findings and not client.images(env["id"]):
raise DockhandError(
f"environment {env.get('name') or env['id']}: Dockhand lists no images but "
f"{len(findings)} findings — Docker host unreachable? Sync aborted.")
try:
hostname = str(client.host(env["id"]).get("hostname") or "")
except DockhandError as e:
# Display only — the findings still import without it.
logger.warning("Dockhand sync: host info for %s unavailable: %s", env.get("name"), e)
hostname = ""
label = _env_label(env, hostname)
host = _host_asset(db, hostname, env.get("publicIp") or env.get("host"))
for f in findings:
name = str(f.get("imageName") or "")
if not name or _BARE_DIGEST.match(name):
stats["untagged_skipped"] += 1
continue
if not str(f.get("cve") or "").strip():
stats["no_id_skipped"] += 1
continue
image = cs.normalise_image(name)
by_image.setdefault(image, []).append(_entry(f))
run = runs.setdefault(image, {}).setdefault(
label, {"image_ids": set(), "containers": {}, "stacks": set()})
if f.get("imageId"):
run["image_ids"].add(_short_id(f["imageId"]))
for c in f.get("containers") or []:
cid = _short_id(c.get("id"))
run["containers"][cid] = str(c.get("name") or "").lstrip("/") or cid
if host is not None:
placed.setdefault(image, {}).setdefault(host.id, set()).update(
run["containers"].values())
run["stacks"].update(s for s in f.get("stacks") or [] if s)
finally:
client.close()
ingested: List[str] = []
for image, entries in sorted(by_image.items()):
try:
got = cs.ingest_image(db, SOURCE_NAME, image, entries,
description=_where(runs[image]),
min_severity=min_severity, only_fixed=only_fixed)
except Exception as e:
db.rollback()
logger.error("Dockhand sync: %s failed: %s", image, e)
stats["errors"].append(f"{image}: {e}")
continue
stats["images"] += 1
ingested.append(image)
stats["baseline_images"] += int(got["baseline"])
for k in ("filtered_severity", "filtered_unfixed", "kev_kept", "vulns_created",
"vulns_merged", "vulns_closed"):
stats[k] += got[k]
stats["images_on_hosts"] = _place(db, placed, ingested)
logger.info("Dockhand sync: %s", {k: v for k, v in stats.items() if k != "errors"})
return stats
+4 -1
View File
@@ -968,7 +968,10 @@ def send_nightly_new_vuln_digest(db: Session) -> dict:
vulns = (
db.query(Vulnerability)
.filter(Vulnerability.status.in_(active),
Vulnerability.detected_at >= since)
Vulnerability.detected_at >= since,
# A container image's first report is a silent baseline,
# see container_scan_service.
Vulnerability.baseline == False) # noqa: E712
.all()
)
stats = dispatch_new_vuln_notifications(db, vulns, respect_schedule=False)
@@ -378,6 +378,8 @@ def build_index(db: Session) -> Dict[str, list]:
"component": ((v0.get("package") or {}).get("name") or "").strip(),
"cvss": _cvss_of(adv),
"sev": _SEV.get((adv.get("severity") or "").lower()),
# The maintainer's word as published, for the badge.
"vendor_sev": (adv.get("severity") or "").strip() or None,
"range": v0.get("vulnerable_version_range"),
"patched": v0.get("patched_versions"),
"summary": (adv.get("summary") or "")[:400],
@@ -506,6 +508,7 @@ def scan_asset(db: Session, asset, packages: list, new_ids: Optional[list] = Non
# A fixed_version is written once and never revised, so a wrong one
# outlives the finding; empty is recoverable.
c = {"cve": e["cve"], "cvss": e.get("cvss"), "severity": e.get("sev"),
"vendor_sev": (f"GitHub ({repo})", e.get("vendor_sev")),
"fixed": (str(e.get("patched") or "").strip().lstrip("vV")
if _vt(e.get("patched")) else None),
# The advisory's own summary. It was cached but never passed
+338
View File
@@ -0,0 +1,338 @@
"""
IGEL Security Notices (ISN) findings for IGEL OS endpoints and the UMS server.
The vendor source for IGEL, for the same reason TeamViewer has its bulletins
(teamviewer_bulletin_service) and Firefox has MFSA: the CVE feeds do not see
everything the vendor states.
1. NOT EVERY ISN HAS A CVE. ISN 2026-09 (UMS information disclosure, 8.6),
ISN 2025-31 (stored XSS in UMS, 8.0) and ISN 2025-24 (command execution
in IGEL OS, 8.8) carry no CVE id at all a CVE-keyed source cannot see
them however well it parses. The ISN id stands in as the identifier,
exactly as TV-, GHSA- and NESSUS-PLUGIN- ids already do in this schema.
2. THE FIX IS STATED PER RELEASE LINE, in the "Update Instructions" section:
"OS 12: Update to IGEL OS 12.7.1. OS 11: Update to IGEL OS 11.10.410".
That is exactly the per-line bound scan_asset_igel needs, and for the
ISNs without a CVE it is the only place it is written down.
The page is prose, not a table. What is read:
* the header "First published … CVSS:3.1: 8.6 (High) CVSS:3.1/AV:N/…"
for the score and severity (both optional: 66 of 198 ISNs state one of
them loosely or not at all);
* every CVE id anywhere on the page;
* the "Update Instructions" section, split at its release-line anchors
("OS 12:", "OS 11:", "UMS 12:", "UMS:") the first version in each chunk
is that line's fix.
A chunk whose version is not on the line's own major is NOT a firmware bound
and is dropped: "OS 12: Update the Chromium app to 148.0.7778.96" is an app
version, and the base-system version on the asset says nothing about which
Chromium app is installed. The one exception is a UMS line told to upgrade to
a newer major ("UMS 6: Upgrade to UMS 12.02.130"), which affects the whole old
line. Same rule as TeamViewer's "affected, no bound": a chunk without a
version ("IGEL is preparing a fixed OS 12 base system") yields nothing, since
"affected, no fix yet" cannot become a per-device verdict without flagging
every device forever once the vendor forgets to update the page.
Two products are in scope and both come from the asset's OS string, which
the UMS connector writes: "IGEL OS" for endpoints (release from the version,
never from the name see cvelistv5_scan_service.IGEL_OS_RE) and "IGEL
Universal Management Suite" for the server. ISNs for ICG, OS Creator, W10 and
the Windows Embedded line have no anchor and are skipped.
Findings are written through the app-scan upsert with the app-scan source.
The OS path relies on scan_asset_igel's reconcile (it runs after this one and
folds the shared `touched` set in); the UMS server had no reconcile at all, so
this pass closes its own stale findings.
"""
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
INDEX_SETTING = "igel_isn_cache_v1"
INDEX_TS_SETTING = "igel_isn_cache_v1_ts"
TTL_HOURS = 24
BASE = "https://kb.igel.com/en/security-safety/current"
_SLUG_RE = re.compile(r'href="/en/security-safety/current/(isn-\d{4}-\d+[^"#?]*)"', re.I)
# "OS 12:", "IGEL OS 11:", "UMS 12:", "UMS:", "UMS 6.x:". The colon is required:
# without it "IGEL OS 11.10.410" inside a chunk reads as a new anchor "OS 11"
# and the fix degrades to "10.410".
_ANCHOR_RE = re.compile(
r"(?:IGEL\s+)?\b(OS|UMS|Universal Management Suite)\b\s*(\d+)?(?:\.x)?\s*:", re.I)
_VER_RE = re.compile(r"(?<![\d.])(\d+\.\d+(?:\.\d+)*)")
_CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}", re.I)
_SEV_RE = re.compile(r"\b(critical|high|medium|moderate|low)\b", re.I)
_SEV = {"critical": "critical", "high": "high", "medium": "medium",
"moderate": "medium", "low": "low"}
UMS_LABEL = "IGEL Universal Management Suite"
def _plain_text(fragment: str) -> str:
import html as _html
return re.sub(r"\s+", " ",
_html.unescape(re.sub(r"(?is)<[^>]+>", " ", fragment))).strip()
def _sections(body: str) -> Dict[str, str]:
"""heading → text, split at h2-h4."""
parts = re.split(r"(?is)<h[2-4][^>]*>(.*?)</h[2-4]>", body)
out: Dict[str, str] = {}
for i in range(1, len(parts), 2):
# The Confluence export appends its own <script> after the article.
out[_plain_text(parts[i])] = re.sub(r"// Mark block images.*", "",
_plain_text(parts[i + 1]))
return out
def _major(version: str) -> int:
return int(version.split(".", 1)[0])
def _parse_bounds(instructions: str) -> List[dict]:
"""Update Instructions text → [{prod, release, fix, all}]."""
bounds: List[dict] = []
anchors = list(_ANCHOR_RE.finditer(instructions))
if not anchors:
# "Update to UMS 12.11.100 or newer when available." — one line, no
# prefix. The product is the word, the release is the fix's major.
if re.search(r"\b(UMS|IGEL OS)\b", instructions):
m = _VER_RE.search(instructions)
if m:
prod = "ums" if re.search(r"\bUMS\b", instructions) else "os"
bounds.append({"prod": prod, "release": _major(m.group(1)),
"fix": m.group(1), "all": False})
return bounds
for i, a in enumerate(anchors):
end = anchors[i + 1].start() if i + 1 < len(anchors) else len(instructions)
chunk = instructions[a.end():end]
m = _VER_RE.search(chunk)
if not m:
continue
fix = m.group(1)
prod = "os" if a.group(1).upper() == "OS" else "ums"
release = int(a.group(2)) if a.group(2) else _major(fix)
if _major(fix) == release:
bounds.append({"prod": prod, "release": release, "fix": fix, "all": False})
elif prod == "ums" and _major(fix) > release:
bounds.append({"prod": prod, "release": release, "fix": fix, "all": True})
# else: an app version on an OS line — not a firmware bound.
return bounds
def _parse_isn(slug: str, html_text: str) -> Optional[dict]:
m = re.search(r"(?is)<(main|article)[^>]*>(.*?)</\1>", html_text)
body = m.group(2) if m else html_text
secs = _sections(body)
text = _plain_text(body)
t = re.search(r"(?is)<h1[^>]*>(.*?)</h1>", body)
title = _plain_text(t.group(1)) if t else slug
# From the slug, not the heading: ISN 2023-18's h1 is written without
# the "ISN 2023-18:" prefix every other notice carries.
m = re.match(r"isn-(\d{4})-(\d+)", slug, re.I)
if not m:
return None
isn_id = f"ISN-{m.group(1)}-{m.group(2)}"
# Header = after the LAST "First published" (an "Updated … (fix version
# to 12.8.1)" line may precede it) and before "Summary".
head = text.split("Summary", 1)[0]
if "First published" in head:
head = head.rsplit("First published", 1)[1]
head = re.sub(r"\b3\.[01]\b", "", head) # the "CVSS:3.1" version token
cvss = None
for sm in re.finditer(r"(?<![\d.])(\d{1,2}\.\d)(?![\d.])", head):
if float(sm.group(1)) <= 10:
cvss = float(sm.group(1))
break
sm = _SEV_RE.search(head)
sev = _SEV.get(sm.group(1).lower()) if sm else None
instr = next((v for k, v in secs.items()
if re.search(r"update|mitigation|resolution", k, re.I)), "")
affected = _parse_bounds(instr)
if not affected:
return None
cves = list(dict.fromkeys(c.upper() for c in _CVE_RE.findall(text)))
desc = " ".join(x for x in (secs.get("Summary"), secs.get("Details")) if x)
return {"id": isn_id, "url": f"{BASE}/{slug}", "title": title,
"cves": cves, "cvss": cvss, "sev": sev,
"desc": desc[:2000] or None, "affected": affected}
def build_index(db: Session) -> Dict[str, dict]:
"""Fetch the ISN overview, then every notice it links (~200 pages)."""
import httpx
index: Dict[str, dict] = {}
with httpx.Client(timeout=httpx.Timeout(30.0, connect=10.0),
follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0 (+security scanner)"}) as client:
try:
r = client.get(BASE)
r.raise_for_status()
except Exception as e:
logger.warning("igel-isn: overview fetch failed: %s", e)
return _stored(db) or {}
slugs = sorted({s.lower() for s in _SLUG_RE.findall(r.text)})
if not slugs:
logger.warning("igel-isn: overview linked no notices — keeping the last index")
return _stored(db) or {}
failed = 0
for slug in slugs:
try:
p = client.get(f"{BASE}/{slug}")
if p.status_code != 200:
failed += 1
continue
entry = _parse_isn(slug, p.text)
except Exception as e:
failed += 1
logger.debug("igel-isn: %s failed: %s", slug, e)
continue
if entry:
index[slug] = entry
if failed and not index:
return _stored(db) or {}
no_cve = sum(1 for e in index.values() if not e["cves"])
logger.info("igel-isn: %d notices, %d with a firmware/UMS bound "
"(%d without a CVE id), %d unreachable",
len(slugs), len(index), no_cve, failed)
if not index:
# Every page answered and none parsed — a markup change. Storing {}
# would leave every ISN finding untouched for a scan, which the
# reconcile reads as "no longer detected" → patched.
logger.warning("igel-isn: %d pages parsed, no bounds — keeping the last index",
len(slugs))
return _stored(db) or {}
_store(db, index)
return index
def _store(db: Session, index: Dict[str, dict]) -> None:
for key, val in ((INDEX_SETTING, json.dumps(index)),
(INDEX_TS_SETTING, datetime.now().isoformat())):
row = db.query(Setting).filter(Setting.key == key).first()
if row:
row.value = val
else:
db.add(Setting(key=key, value=val))
db.commit()
def _stored(db: Session) -> Optional[Dict[str, dict]]:
row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
if not row or not row.value:
return None
try:
return json.loads(row.value)
except json.JSONDecodeError:
return None
def load_index(db: Session) -> Optional[Dict[str, dict]]:
ts = db.query(Setting).filter(Setting.key == INDEX_TS_SETTING).first()
if not ts or not ts.value:
return None
try:
if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=TTL_HOURS):
return None
except ValueError:
return None
return _stored(db)
def get_index(db: Session) -> Dict[str, dict]:
idx = load_index(db)
if idx is not None:
return idx
try:
return build_index(db)
except Exception as e:
logger.warning("igel-isn index build failed: %s", e)
return _stored(db) or {}
def _product(asset) -> Optional[str]:
from app.services import cvelistv5_scan_service as c5
name = (asset.operating_system or "").strip()
if c5.IGEL_OS_RE.match(name):
return "os"
if name.lower().startswith(UMS_LABEL.lower()):
return "ums"
return None
def _affected(installed: str, bound: dict) -> bool:
from app.services import app_cve_scanner_service as cpe
if bound["all"]:
return True
return (cpe._vcmp(installed, bound["fix"]) or 0) < 0
def scan_asset(db: Session, asset, new_ids: Optional[list] = None,
touched: Optional[set] = None, index: Optional[dict] = None) -> int:
"""Match an IGEL OS endpoint or the UMS server against the ISNs.
Returns findings upserted. Caller commits."""
from app.services import app_cve_scanner_service as cpe
from app.services import cvelistv5_scan_service as c5
prod = _product(asset)
if not prod:
return 0
if index is None:
index = get_index(db)
if not index:
return 0
cver = cpe._clean_version(asset.os_version or "")
if not cver:
return 0
release = c5.igel_release(cver)
if release is None:
return 0
if new_ids is None:
new_ids = []
label = c5.IGEL_LABEL if prod == "os" else UMS_LABEL
count = 0
still_affected: set = set()
for entry in index.values():
bound = next((b for b in entry["affected"]
if b["prod"] == prod and b["release"] == release
and _affected(cver, b)), None)
if not bound:
continue
for ident in (entry["cves"] or [entry["id"]]):
still_affected.add(ident.upper())
c = {"cve": ident, "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": bound["fix"],
"desc": entry.get("desc"),
"refs": json.dumps([entry["url"]])}
try:
before = len(new_ids)
cpe._upsert(db, asset, label, asset.os_version or cver, c,
new_ids, touched=touched, vendor="IGEL")
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("igel-isn upsert failed (%s on %s): %s", ident, asset.id, e)
if prod == "ums":
# The endpoint path reconciles in scan_asset_igel (which runs after
# this and folds `touched` in). The UMS server has no other reconcile:
# the CPE OS scan before this one never closed anything.
c5._resolve_stale_appliance(db, asset, label,
still_affected | set(touched or ()), cver,
tag="igel-isn")
return count
+52 -14
View File
@@ -31,6 +31,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetSource, AssetStatus
from app.services.asset_matching import match_by_hostname, match_by_ip, stable_serial
logger = logging.getLogger(__name__)
@@ -232,24 +233,39 @@ def _resolve_ip(host: str) -> Optional[str]:
_SYNC_ADVISORY_LOCK_KEY = 0x54560103 # "TV" + 03
# A network device Netdisco discovered is never a thin client or the UMS box: a
# name or address shared with one is another device (field report 2026-09-15).
_NOT_IGEL = (Asset.netdisco_device_ip,)
def _find_or_create_asset(db: Session, *, unit_id: Optional[str], hostname: str,
auto_create: bool, ip: Optional[str] = None,
match_on_ip: bool = False):
"""Match a UMS object to an asset by unit ID first, hostname second.
match_on_ip: bool = False, serial: Optional[str] = None,
live_unit_ids: frozenset = frozenset()):
"""Match a UMS object to an asset: unit ID, serial, hostname.
Unit ID first because a thin client is renamed constantly it is named
after its desk, its user or its room, and all three change while the unit
ID is derived from the MAC and survives a factory reset and a re-
registration. A rename must not fork the asset: the finding history hangs
off it.
ID is the MAC and survives a factory reset and a re-registration. A rename
must not fork the asset: the finding history hangs off it.
The serial second, for the case the MAC does not survive: a board or NIC
swap, a UD Pocket registered again. It only takes over an asset whose unit
ID is absent from THIS run (`live_unit_ids`) two UD Pockets booted on the
same PC report the same host serial and are still two devices, and the
serial must not pull one onto the other (same rule as the Wazuh agent id,
0936cf4).
"""
unit_id = (unit_id or "").strip() or None
serial = stable_serial(serial)
hostname = (hostname or "").strip()
short = hostname.split(".")[0] if hostname else ""
def _pin(a):
if unit_id and a.igel_unit_id != unit_id:
a.igel_unit_id = unit_id
if serial and a.igel_serial != serial:
a.igel_serial = serial
if unit_id:
a = db.query(Asset).filter(Asset.igel_unit_id == unit_id).first()
@@ -257,13 +273,21 @@ def _find_or_create_asset(db: Session, *, unit_id: Optional[str], hostname: str,
_pin(a)
return a, "unit"
if serial:
for a in db.query(Asset).filter(Asset.igel_serial == serial).order_by(Asset.id):
if not a.igel_unit_id or a.igel_unit_id not in live_unit_ids:
_pin(a)
return a, "serial"
for candidate in [c for c in (hostname, short) if c]:
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
a = match_by_hostname(db, candidate, pin_col=Asset.igel_unit_id, pin_value=unit_id,
ip=ip, not_pinned_by=_NOT_IGEL)
if a:
_pin(a)
return a, "hostname"
if short:
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
a = match_by_hostname(db, f"{short}.%", pin_col=Asset.igel_unit_id,
pin_value=unit_id, ip=ip, not_pinned_by=_NOT_IGEL)
if a:
_pin(a)
return a, "hostname-fqdn-prefix"
@@ -275,18 +299,20 @@ def _find_or_create_asset(db: Session, *, unit_id: Optional[str], hostname: str,
# the network name already cover the case this was for. The UMS SERVER is
# the one fixed address here, and it passes match_on_ip=True.
if ip and match_on_ip:
a = db.query(Asset).filter(Asset.ip_address == ip).first()
a = match_by_ip(db, ip, hostname, not_pinned_by=_NOT_IGEL)
if a:
_pin(a)
return a, "ip"
if auto_create and hostname:
a = Asset(hostname=short or hostname, ip_address=ip,
igel_unit_id=unit_id, source=AssetSource.IGEL,
igel_unit_id=unit_id, igel_serial=serial, source=AssetSource.IGEL,
status=AssetStatus.ACTIVE)
db.add(a)
db.flush()
logger.info("IGEL sync: auto-created asset %s", a.hostname)
from app.services.asset_lifecycle import created
created(db, a, "IGEL")
return a, "created"
return None, "skipped"
@@ -308,7 +334,7 @@ def run_igel_sync(db: Session) -> dict:
def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
from app.services.asset_lifecycle import reconcile_igel_by_seen_ids
from app.services.asset_lifecycle import reconcile_igel_by_seen_ids, rename
auto_create = bool(cfg.get("auto_create_assets", True))
sync_devices = bool(cfg.get("sync_devices", True))
@@ -356,6 +382,7 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
stats["errors"].append(f"ums server: {e}")
# --- endpoint devices ---
live_unit_ids = frozenset(d["unit_id"] for d in devices if d.get("unit_id"))
for d in devices:
stats["devices"] += 1
try:
@@ -376,7 +403,8 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
hostname = d.get("network_name") or d.get("name") or ""
asset, how = _find_or_create_asset(
db, unit_id=d.get("unit_id"), hostname=hostname,
auto_create=auto_create, ip=d.get("ip_address"))
auto_create=auto_create, ip=d.get("ip_address"),
serial=d.get("serial"), live_unit_ids=live_unit_ids)
if not asset:
continue
if how == "created":
@@ -384,9 +412,9 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
else:
stats["assets_matched"] += 1
# Renames are the norm on a thin-client estate; matched by unit
# ID means the name UMS reports now is the current one.
if how == "unit" and hostname and asset.hostname != hostname:
asset.hostname = hostname.split(".")[0] or hostname
# ID or serial means the name UMS reports now is the current one.
if how in ("unit", "serial") and hostname and asset.hostname != hostname:
rename(db, asset, hostname.split(".")[0] or hostname, "IGEL")
if d.get("ip_address"):
asset.ip_address = d["ip_address"][:45]
if resolved:
@@ -499,7 +527,9 @@ def _run_cve_scan(db: Session, asset_ids: set) -> int:
return 0
from app.services import app_cve_scanner_service as cpe
from app.services import cvelistv5_scan_service as c5
from app.services import igel_isn_service as isn
index = c5.load_index(db) or {}
isn_index = isn.get_index(db)
if not index:
logger.info("IGEL sync: no cvelistV5 index yet — CVE pass deferred "
"to the nightly app-CVE scan")
@@ -511,6 +541,14 @@ def _run_cve_scan(db: Session, asset_ids: set) -> int:
total += cpe.scan_asset_os(db, asset, new_ids, touched=touched)
except Exception as e:
logger.warning("IGEL CPE scan failed for %s: %s", asset.hostname, e)
# The vendor's own notices — the only source for the ISNs without a
# CVE id. Between the CPE pass and the cvelistV5 one on purpose: it
# folds the CPE hits into the UMS reconcile, and scan_asset_igel
# folds its hits into the endpoint one.
try:
total += isn.scan_asset(db, asset, new_ids, touched=touched, index=isn_index)
except Exception as e:
logger.warning("IGEL ISN scan failed for %s: %s", asset.hostname, e)
if not index:
continue
try:
+84 -31
View File
@@ -16,6 +16,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetSource, AssetStatus
from app.services.asset_matching import match_by_hostname, stable_serial
logger = logging.getLogger(__name__)
@@ -138,6 +139,7 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
device_id = (device.get("id") or "").strip() or None
aad_id = _aad_id(device)
hostname = _clean_device_name(device)
serial = stable_serial(device.get("serialNumber"))
def _pin(a):
# Adopt the ids Intune just gave us, so future syncs (and the Defender
@@ -159,6 +161,12 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
a.intune_device_id = device_id
if aad_id and a.aad_device_id != aad_id:
a.aad_device_id = aad_id
# Written once and then left alone, unlike the two ids above: those
# are enrolment facts and change with the enrolment, the serial is the
# hardware and does not. Overwriting it would hand the identity to
# whichever record the run happened to process last.
if serial and not a.intune_serial:
a.intune_serial = serial
if device_id:
a = db.query(Asset).filter(Asset.intune_device_id == device_id).first()
@@ -172,14 +180,36 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
_pin(a)
return a, "aad_id"
# Before the name: the hardware. A wipe-and-re-enrol issues a new device
# id, so the two id stages above miss and the name is all that is left —
# and a name is not an identifier (DESKTOP-XXXXXXX, a batch of userless
# Androids). The serial tells the two apart.
if serial:
# limit(2): a serial two assets carry identifies neither. The pin is
# written once, from this same check, so it should not happen — but a
# placeholder that slipped the filter would look exactly like this,
# and then the name still decides.
rows = db.query(Asset).filter(Asset.intune_serial == serial).limit(2).all()
if len(rows) == 1:
_pin(rows[0])
return rows[0], "intune-serial"
if rows:
logger.info("Intune sync: serial %s is carried by more than one "
"asset — not an identifier", serial)
short = hostname.split(".")[0] if hostname else ""
# Pinned on the SERIAL, not on intune_device_id: the device id changes on
# re-enrolment and guarding on it would fork the asset of every re-enrolled
# device. An asset carrying a different serial belongs to other hardware.
for candidate in [c for c in (hostname, short) if c]:
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
a = match_by_hostname(db, candidate, pin_col=Asset.intune_serial,
pin_value=serial)
if a:
_pin(a)
return a, "hostname"
if short:
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
a = match_by_hostname(db, f"{short}.%", pin_col=Asset.intune_serial,
pin_value=serial)
if a:
_pin(a)
return a, "hostname-fqdn-prefix"
@@ -188,6 +218,7 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
a = Asset(
hostname=short or hostname,
intune_device_id=device_id,
intune_serial=serial,
aad_device_id=aad_id,
source=AssetSource.INTUNE,
status=AssetStatus.ACTIVE,
@@ -195,6 +226,8 @@ def _find_or_create_asset(db: Session, device: dict, auto_create: bool):
db.add(a)
db.flush()
logger.info("Intune sync: auto-created asset %s", a.hostname)
from app.services.asset_lifecycle import created
created(db, a, "Intune")
return a, "created"
return None, "skipped"
@@ -240,10 +273,16 @@ def _dedupe_reenrolled(devices: list) -> tuple:
Collapsing them here, before anything touches an asset, is the whole fix:
the asset is pinned to the live enrolment and its app list REPLACES the
old one instead of competing with it. Keyed on the cleaned short hostname
the asset match ends up on anyway, so nothing collapses here that would
not have collapsed onto one asset regardless. Devices with no usable name
are never merged.
old one instead of competing with it.
The key is the name AND the hardware serial, not the name alone. Two
DIFFERENT devices can share a name a Windows default DESKTOP-XXXXXXX, a
batch of userless Androids and collapsing those dropped one of them out
of the sync entirely: no asset, no inventory, no findings, and nothing in
the UI that says a system is missing. A re-enrolment keeps its serial (same
hardware), so the case this function exists for still collapses; devices
that report no serial fall back to the name and behave as before. Devices
with no usable name are never merged.
"""
newest: dict = {}
unnamed: list = []
@@ -253,6 +292,7 @@ def _dedupe_reenrolled(devices: list) -> tuple:
if not key:
unnamed.append(d)
continue
key = (key, stable_serial(d.get("serialNumber")) or "")
prev = newest.get(key)
if prev is None:
newest[key] = d
@@ -261,10 +301,12 @@ def _dedupe_reenrolled(devices: list) -> tuple:
else (d, prev))
newest[key] = live
dropped += 1
logger.info("Intune sync: %s enrolled more than once — using device id "
"%s (check-in %s), ignoring stale %s (check-in %s)",
key, live.get("id"), live.get("lastSyncDateTime"),
stale.get("id"), stale.get("lastSyncDateTime"))
logger.info("Intune sync: %s (serial %s) enrolled more than once — "
"using device id %s (check-in %s), ignoring stale %s "
"(check-in %s)",
key[0], key[1] or "-", live.get("id"),
live.get("lastSyncDateTime"), stale.get("id"),
stale.get("lastSyncDateTime"))
return unnamed + list(newest.values()), dropped
@@ -295,7 +337,7 @@ def run_intune_sync(db: Session, asset_id: Optional[int] = None) -> dict:
def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
from app.services import eol_service
from app.services.asset_lifecycle import reconcile_intune_by_seen_ids
from app.services.asset_lifecycle import reconcile_intune_by_seen_ids, rename
auto_create = bool(cfg.get("auto_create_assets", True))
detected_apps_enabled = bool(cfg.get("detected_apps", True))
@@ -328,8 +370,12 @@ def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
# Rename tracking: matched by a stable id → adopt the current
# (cleaned) device name so autodeploy renames propagate.
new_name = _clean_device_name(device)
if how in ("intune_id", "aad_id") and new_name and asset.hostname != new_name:
asset.hostname = new_name.split(".")[0] or new_name
# "intune-serial" belongs here too: it means the id stages
# missed (the device was re-enrolled) and we found the same
# HARDWARE — so the name Intune reports now is the current one.
if how in ("intune_id", "aad_id", "intune-serial") \
and new_name and asset.hostname != new_name:
rename(db, asset, new_name.split(".")[0] or new_name, "Intune")
# refresh inventory fields
os_name = _os_string(device)
if os_name:
@@ -395,7 +441,7 @@ def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
or asset.defender_machine_id)
stats["app_findings"] += _run_app_inventory(
db, asset, pkgs, reconcile=sole_source,
seen_eol_ids=seen_eol_ids)
inventory=SOURCE_NAME, seen_eol_ids=seen_eol_ids)
except Exception as e:
logger.debug("Intune detectedApps failed for %s: %s", asset.hostname, e)
except Exception as e:
@@ -438,10 +484,14 @@ def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
def _run_app_inventory(db: Session, asset, packages: list, *,
reconcile: bool = False,
inventory: Optional[str] = None,
seen_eol_ids: Optional[set] = None) -> int:
"""Feed an inventory (Intune detectedApps, Defender software) into the
existing EOL + M365 detection. Returns findings upserted (best-effort).
`inventory` names whose list this is, so a finding these scanners reopen
cannot undo a close that is newer than the list (audit_events).
`reconcile` retracts the EOL findings this list no longer confirms
i.e. it REPLACES the asset's software inventory rather than adding to it.
Only the caller knows whether it holds the whole inventory, so it is off by
@@ -449,22 +499,25 @@ def _run_app_inventory(db: Session, asset, packages: list, *,
its own partial list) "not in my list" is not "uninstalled", and retracting
would flap against the other source.
"""
from app.services.audit_events import scanning_inventory
count = 0
try:
from app.services import eol_service
count += eol_service.run_eol_for_packages(
db, asset, packages,
reconcile=reconcile, seen_ids=seen_eol_ids)["findings"]
except Exception as e:
logger.debug("Intune EOL-for-packages failed on %s: %s", asset.hostname, e)
try:
from app.services import m365_service
count += m365_service.run_m365_for_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune M365-for-packages failed on %s: %s", asset.hostname, e)
try:
from app.services import app_cve_scanner_service
count += app_cve_scanner_service.scan_asset_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune app-cve scan failed on %s: %s", asset.hostname, e)
with scanning_inventory(inventory):
try:
from app.services import eol_service
count += eol_service.run_eol_for_packages(
db, asset, packages,
reconcile=reconcile, seen_ids=seen_eol_ids)["findings"]
except Exception as e:
logger.debug("Intune EOL-for-packages failed on %s: %s", asset.hostname, e)
try:
from app.services import m365_service
count += m365_service.run_m365_for_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune M365-for-packages failed on %s: %s", asset.hostname, e)
try:
from app.services import app_cve_scanner_service
count += app_cve_scanner_service.scan_asset_packages(db, asset, packages)
except Exception as e:
logger.debug("Intune app-cve scan failed on %s: %s", asset.hostname, e)
return count
+237
View File
@@ -0,0 +1,237 @@
"""
Jev config hint one structured question per CVE text, to TypeSafe Jev
directly or via OpenRouter Decisions.
Jev (TypeSafe) is a decision model: typed answer + probability, no prose. It
is asked only what no feed answers KEV / EUVD / EPSS / SSVC and the CVSS
vector already cover "exploited?", "how exploitable?", "remote?":
Is this CVE only exploitable with an optional feature, module or
non-default configuration enabled?
The answer (noul probability) is stored per CVE as a HINT the patch plan lists
("check whether this applies to your setup"). It never closes a finding and
never enters priority_score: a finding closes only when its scanners retract
it (README.DEV.md, "Cross-source contract").
Off by default: JEV_TRIAGE_ENABLED env or setting `jev_triage_enabled`, plus
a key a TypeSafe key (TYPESAFE_API_KEY / `typesafe_api_key`) goes straight
to api.typesafe.ai, else the OpenRouter key the AI remediation already uses.
Both endpoints take the same body and answer the same `answers`. Only public
CVE text is sent no hostnames, no asset data.
"""
import json
import logging
from datetime import datetime
from typing import Callable, Optional, Tuple
import httpx
from sqlalchemy import distinct, func
from sqlalchemy.orm import Session
from app.models.audit_log import AuditLog, AuditEventType
from app.models.vulnerability import Vulnerability
from app.services.ai_service import _cfg
from app.services.patch_plan_service import open_work
logger = logging.getLogger(__name__)
# (url, pinned model) per endpoint. Pinned, not the latest alias: a silent
# model swap would shift every hint. The two ids differ, so the model goes
# with the endpoint and is not configurable on its own.
TYPESAFE_ENDPOINT = ("https://api.typesafe.ai/v1/systemone", "jev-1.13.0")
OPENROUTER_ENDPOINT = ("https://openrouter.ai/api/alpha/decisions", "typesafe/jev-1.13")
HTTP_TIMEOUT = 20.0
MAX_CVES_PER_RUN = 500
# This many CVE-specific failures in a row are the request, not the CVEs.
MAX_CONSECUTIVE_SKIPS = 5
# "noul" is Jev's yes/no answer type: the answer is the probability of yes.
QUESTION_KEY = "non_default_config"
QUESTION = {
"type": "noul",
"instructions": (
"Based on the description, is this vulnerability only exploitable when an "
"optional feature, module, plugin or non-default configuration is enabled, "
"rather than in a default installation of the product?"
),
}
def parse_noul(data: dict) -> float:
try:
p = data["answers"][QUESTION_KEY]["noul"]
except (KeyError, TypeError):
raise ValueError(f"no {QUESTION_KEY} answer in Jev response")
if isinstance(p, bool) or not isinstance(p, (int, float)) or not 0.0 <= p <= 1.0:
raise ValueError(f"Jev returned {p!r}, not a probability")
return float(p)
def usage_tokens(data: dict) -> int:
"""Tokens this answer cost, 0 when the endpoint does not report any.
Neither endpoint promises a `usage` block, and a missing one must never
cost an answer so this only ever reads what is there.
"""
u = data.get("usage") if isinstance(data, dict) else None
if not isinstance(u, dict):
return 0
total = u.get("total_tokens")
if not isinstance(total, (int, float)):
total = sum(u.get(k) or 0 for k in ("prompt_tokens", "completion_tokens")
if isinstance(u.get(k), (int, float)))
try:
return max(0, int(total))
except (TypeError, ValueError):
return 0
def _cve_specific(e: Exception) -> bool:
"""An unusable answer, or a 4xx that is about the request rather than the
account (401/402/403 auth or credit, 429 rate limit)."""
if isinstance(e, ValueError):
return True
if isinstance(e, httpx.HTTPStatusError):
code = e.response.status_code
return 400 <= code < 500 and code not in (401, 402, 403, 429)
return False
def ask_jev(url: str, api_key: str, model: str, state: dict) -> Tuple[float, int]:
"""(probability, tokens the endpoint reported for this call)."""
r = httpx.post(
url,
headers={"Authorization": f"Bearer {api_key}"},
json={"model": model, "state": state, "questions": {QUESTION_KEY: QUESTION}},
timeout=HTTP_TIMEOUT,
)
r.raise_for_status()
data = r.json()
tokens = usage_tokens(data)
try:
return parse_noul(data), tokens
except ValueError as e:
# The call went through and is billed; only the payload is unusable.
# The caller skips this CVE but must still count what it cost.
e.tokens = tokens
raise
def audit_run(db: Session, stats: dict, cves: list, model: str) -> None:
"""One audit row per run: what it cost and what it covered.
Reuses AI_ANALYSIS_REQUESTED a Jev run is an AI call, and an own event
type would be an enum migration for one row a night. Never raises: this
is bookkeeping, the answers are already committed.
A run that did nothing (nothing pending, or disabled) writes nothing
an audit log that fills with "0 CVEs" every night hides the real rows.
"""
if not (stats["checked"] or stats["error"] or stats["skipped_cves"]):
return
try:
hosts = 0
if cves:
hosts = open_work(db.query(func.count(distinct(Vulnerability.asset_id)))) \
.filter(Vulnerability.cve_id.in_(cves)).scalar() or 0
stats["hosts"] = hosts
tokens = stats.get("tokens") or 0
if tokens:
cost = f"{tokens:,} tokens"
elif stats["checked"]:
cost = "tokens not reported by the endpoint"
else:
# Nothing was answered — saying the endpoint reports no usage
# would be a claim about a call that never came back.
cost = "no tokens counted"
desc = (f"Jev triage: {stats['checked']} CVEs checked on {hosts} hosts, "
f"{cost} ({model})")
if stats.get("error"):
desc += f" — stopped: {stats['error']}"
db.add(AuditLog(
user_id=None, # System/Auto, like the sync events
event_type=AuditEventType.AI_ANALYSIS_REQUESTED,
event_description=desc[:500],
resource_type="jev_triage",
new_value=json.dumps({
"checked": stats["checked"], "hosts": hosts, "tokens": tokens,
"model": model, "cves": cves,
"skipped_cves": stats.get("skipped_cves") or [],
"error": stats.get("error"),
}),
timestamp=datetime.now(),
))
db.commit()
except Exception as e:
logger.warning("Jev triage audit entry failed: %s", e)
db.rollback()
def run_jev_triage(db: Session,
ask: Optional[Callable[[str, str, str, dict], Tuple[float, int]]] = None) -> dict:
ask = ask or ask_jev
enabled = _cfg(db, "JEV_TRIAGE_ENABLED", "jev_triage_enabled").lower() in ("1", "true", "yes")
# TypeSafe first when both are set: same answer, one hop less.
api_key = _cfg(db, "TYPESAFE_API_KEY", "typesafe_api_key")
url, model = TYPESAFE_ENDPOINT
if not api_key:
api_key = _cfg(db, "OPENROUTER_API_KEY", "openrouter_api_key")
url, model = OPENROUTER_ENDPOINT
if not (enabled and api_key):
return {"skipped": "disabled or no TypeSafe / OpenRouter key", "checked": 0}
# One row per CVE is enough — the question is about the CVE text.
# ponytail: answered once, never re-asked; clear jev_checked_at to redo.
pending = (
open_work(db.query(Vulnerability.cve_id, Vulnerability.title,
Vulnerability.description, Vulnerability.package_name))
.filter(Vulnerability.jev_checked_at.is_(None))
.filter(Vulnerability.cve_id.like("CVE-%"))
.filter(Vulnerability.description.isnot(None))
.order_by(Vulnerability.priority_score.desc())
.all()
)
seen, streak = set(), 0
stats = {"checked": 0, "tokens": 0, "hosts": 0, "skipped_cves": [], "error": None}
answered = []
for cve, title, description, product in pending:
if cve in seen:
continue
seen.add(cve)
if len(seen) > MAX_CVES_PER_RUN:
break
try:
answer = ask(url, api_key, model, {"cve": cve, "product": product, "title": title,
"description": description})
except Exception as e:
# A rejected answer was still paid for (ask_jev attaches what it cost).
stats["tokens"] += getattr(e, "tokens", 0)
streak += 1
if _cve_specific(e) and streak < MAX_CONSECUTIVE_SKIPS:
# This CVE's text or answer, not the API — skip it, go on;
# otherwise it would stop the run at the same CVE every night.
logger.warning("Jev triage skipped %s: %s", cve, e)
stats["skipped_cves"].append(cve)
continue
# Auth, quota, network, server, or the same rejection CVE after
# CVE — systemic, so stop; everything unanswered stays unstamped
# and the next run retries it.
logger.error("Jev triage stopped at %s: %s", cve, e)
stats["error"] = str(e)
break
streak = 0
p, tokens = answer
stats["tokens"] += tokens
answered.append(cve)
db.query(Vulnerability).filter(Vulnerability.cve_id == cve).update(
{"jev_config_dependent": p, "jev_checked_at": datetime.utcnow()},
synchronize_session=False,
)
stats["checked"] += 1
if stats["checked"] % 50 == 0:
db.commit()
db.commit()
audit_run(db, stats, answered, model)
logger.info("Jev triage: %s", stats)
return stats
+80 -1
View File
@@ -70,8 +70,11 @@ def euvd_is_exploited(entry: dict) -> bool:
def _parse_date(s) -> Optional[datetime]:
# fromisoformat, not strptime: this runs once per catalog entry per merge
# AND once per entry per sort — tens of thousands of calls on one dashboard
# load — and strptime costs ~15x more for the same "YYYY-MM-DD".
try:
return datetime.strptime(str(s)[:10], "%Y-%m-%d")
return datetime.fromisoformat(str(s)[:10])
except (ValueError, TypeError):
return None
@@ -96,6 +99,76 @@ def _normalize_sources(sources: Optional[Sequence[str]]) -> tuple:
return picked or ALL_SOURCES
# Rebuilding the merged catalog means parsing ~2 MB of JSON out of `settings`
# and merging every entry — 60 ms of pure Python, on a request the dashboard
# makes four times per page load. The sources behind it refresh once a day, so
# the rebuild is repeated work almost every time.
#
# Memoized on the source caches' own `_updated_at` values, not on a clock: a
# refresh (nightly or by hand) writes a new timestamp and the memo is stale
# the same instant, so "refresh now, look at the page" still shows the new
# catalog. When those timestamps cannot be read at all — a test stub, an
# install whose caches were never filled — the identity of the catalog is
# unknown and nothing is memoized.
# ponytail: per-process. Several workers each keep their own copy; that only
# needs a shared cache if the copies ever have to agree on the same second.
_catalog_memo: Dict[tuple, tuple] = {}
def _ts_keys() -> Dict[str, str]:
"""source id → settings key holding the time of its last successful fetch."""
from app.services.enrichment_service import (
EUVD_CACHE_TS_KEY, KEV_CACHE_TS_KEY, VULNCHECK_CACHE_TS_KEY)
return {"cisa": KEV_CACHE_TS_KEY, "euvd": EUVD_CACHE_TS_KEY,
"vulncheck": VULNCHECK_CACHE_TS_KEY}
def source_status(db: Session) -> Dict[str, dict]:
"""Per source: when its catalog was last fetched (ISO string or None) and
whether it can be fetched at all VulnCheck needs a token. The Advisories
page shows this next to the KEV list; the RSS feeds keep their own stamp."""
from app.models.setting import Setting
from app.services.enrichment_service import vulncheck_token
keys = _ts_keys()
rows = dict(db.query(Setting.key, Setting.value)
.filter(Setting.key.in_(list(keys.values()))).all())
return {s: {"updated_at": rows.get(k),
"configured": s != "vulncheck" or bool(vulncheck_token())}
for s, k in keys.items()}
def refresh_kev_sources(db: Session) -> Dict[str, dict]:
"""Re-fetch every KEV catalog now, past the 24h cache. One dead catalog
must not stop the others, so failures are recorded per source.
A fetch that fails but finds an old cache returns that cache without
raising, so a source can report no error and still keep its old
`updated_at` the timestamp, not the error field, says what is current."""
from app.services import enrichment_service as enr
fetchers = {"cisa": enr.fetch_kev_catalog, "euvd": enr.fetch_euvd_catalogs,
"vulncheck": enr.fetch_vulncheck_kev}
out: Dict[str, dict] = {}
for source, fetch in fetchers.items():
try:
out[source] = {"entries": len(fetch(db, force_refresh=True)), "error": None}
except Exception as e:
logger.warning("kev: %s refresh failed: %s", source, e)
out[source] = {"entries": None, "error": f"{type(e).__name__}: {e}"[:200]}
return out
def _catalog_stamp(db: Session, picked: tuple) -> Optional[str]:
"""Identity of the underlying source caches, or None when unreadable."""
from app.models.setting import Setting
ts_keys = _ts_keys()
try:
rows = (db.query(Setting.key, Setting.value)
.filter(Setting.key.in_([ts_keys[s] for s in picked])).all())
except Exception:
return None
return "|".join(f"{k}={v}" for k, v in sorted(rows)) or None
def get_kev_catalog(db: Session, sources: Optional[Sequence[str]] = None) -> Dict[str, dict]:
"""{CVE-ID: entry} of everything known to be actively exploited.
@@ -108,6 +181,10 @@ def get_kev_catalog(db: Session, sources: Optional[Sequence[str]] = None) -> Dic
yesterday is news yesterday. Per-source dates stay in `source_dates`.
"""
picked = _normalize_sources(sources)
stamp = _catalog_stamp(db, picked)
memo = _catalog_memo.get(picked)
if stamp and memo and memo[0] == stamp:
return memo[1]
out: Dict[str, dict] = {}
def _touch(cve: str, source: str, date_added) -> dict:
@@ -178,6 +255,8 @@ def get_kev_catalog(db: Session, sources: Optional[Sequence[str]] = None) -> Dic
except Exception as ex:
logger.warning("kev: VulnCheck KEV catalog unavailable: %s", ex)
if stamp:
_catalog_memo[picked] = (stamp, out)
return out
+80
View File
@@ -108,6 +108,34 @@ def is_m365_apps(product_name: str) -> bool:
return "microsoft 365 apps" in n or "office 365 proplus" in n
def _anchor_candidates(date_label: str) -> List[str]:
"""Anchor forms for a date heading, most likely first.
Learn slugifies the heading verbatim, so the day is written exactly as the
heading writes it and Microsoft is inconsistent about zero padding
("December 09, 2025" -> #december-09-2025, "June 9, 2026" -> #june-9-2026).
Both forms are offered and the caller keeps the one the page really has.
"""
m = _DATE_RE.fullmatch((date_label or "").strip())
if not m:
return []
month, day, year = m.group(1).lower(), m.group(2), m.group(3)
forms = [day, str(int(day))]
return [f"{month}-{d}-{year}" for d in dict.fromkeys(forms)]
def security_page_url(anchor: Optional[str] = None) -> str:
"""The Learn security-updates page, deep-linked to a monthly section.
`anchor` is a heading id read off the page itself (see
_parse_security_page), so a finding points straight at the release that
lists its CVE instead of the top of a very long page. Without one an
older cache, a heading Microsoft renamed the plain page URL: a link to
the right page beats one to an anchor that isn't there.
"""
return f"{M365_SECURITY_URL}#{anchor}" if anchor else M365_SECURITY_URL
# ============================================================
# page fetch + parse
# ============================================================
@@ -117,6 +145,7 @@ def _parse_security_page(html: str) -> List[dict]:
Each release: {
"date": "May 12, 2026",
"anchor": "may-12-2026" or None, # the section's own heading id
"channel_builds": {channel_name: [(major, rev), ...]}, # max = newest
"cves": ["CVE-2026-40361", ...], # every CVE in the section
}
@@ -127,6 +156,9 @@ def _parse_security_page(html: str) -> List[dict]:
# bullets, so segmenting the flattened text by date heading is robust
# against markup churn.
doc = lxml.html.fromstring(html)
# Heading ids, read before anything is stripped: the deep link on a
# finding uses the anchor the page actually has, never a guessed one.
heading_ids = {str(i) for i in doc.xpath("//h2/@id | //h3/@id")}
for bad in doc.xpath("//script | //style | //nav | //header | //footer"):
bad.getparent().remove(bad)
body = doc.xpath("//main") or [doc]
@@ -156,8 +188,11 @@ def _parse_security_page(html: str) -> List[dict]:
if not cves:
continue
anchor = next((a for a in _anchor_candidates(date_label)
if a in heading_ids), None)
releases.append({
"date": date_label,
"anchor": anchor,
"channel_builds": channel_builds,
"cves": cves,
})
@@ -260,6 +295,7 @@ def detect_missing_cves(
"latest_build": "19929.20162" or None, # newest patched, this channel
"missing_cves": [ ... ], # union, deduped
"behind_releases": [ "May 12, 2026", ... ],
"cve_anchors": {"CVE-...": "may-12-2026"}, # section documenting it
}
"""
out = {
@@ -268,6 +304,7 @@ def detect_missing_cves(
"latest_build": None,
"missing_cves": [],
"behind_releases": [],
"cve_anchors": {},
}
installed = parse_build(installed_version)
if not installed:
@@ -293,10 +330,44 @@ def detect_missing_cves(
if b and installed < b:
cve_set.update(r.get("cves", []))
out["behind_releases"].append(r.get("date"))
# Remember which section documents each CVE, for the evidence
# link on the finding. Releases come newest-first and Microsoft
# occasionally re-lists a CVE in a later section, so the last
# write — the OLDEST section — wins: that is where the fix was
# first published.
for c in r.get("cves", []):
if r.get("anchor"):
out["cve_anchors"][c.upper()] = r["anchor"]
out["missing_cves"] = sorted(cve_set)
return out
def _attach_reference(vuln, url: Optional[str]) -> None:
"""Point a finding at the Learn section it was raised from.
Our own previous link is replaced Microsoft occasionally re-lists a CVE
in a later section, and one page deserves one button while references
other sources wrote stay untouched.
"""
if not url:
return
if vuln.references and not vuln.reference_list:
logger.warning("M365: unusable references value on %s (%r) — replaced",
vuln.cve_id, vuln.references)
vuln.references = None
ours = [r for r in vuln.reference_list
if r.split("#", 1)[0] == M365_SECURITY_URL and r != url]
if ours:
vuln.references = json.dumps(
[r for r in vuln.reference_list if r not in ours])
vuln.add_reference(url)
def _reference_for(result: dict, cve_id: str) -> str:
"""The evidence link for one CVE out of a detect_missing_cves result."""
return security_page_url(result.get("cve_anchors", {}).get(cve_id.upper()))
def upsert_m365_vulnerability(
db: Session,
*,
@@ -305,12 +376,17 @@ def upsert_m365_vulnerability(
product_name: str,
installed_version: str,
fixed_build: Optional[str],
reference_url: Optional[str] = None,
) -> Tuple[Optional[int], bool]:
"""Create/refresh a real-CVE M365 vuln row. Returns (id, was_created).
CVSS/severity are left as a neutral placeholder; the nightly
enrichment (EPSS/KEV/NVD dates) and the Correct-CVSS job refine them.
These are real CVE ids, so they enrich like any other CVE.
`reference_url` is the Learn security-updates section this finding was
derived from the only public record of the fix (see security_page_url).
Passed on every run, so findings raised before it existed pick it up.
"""
from app.models.vulnerability import (
Vulnerability, VulnerabilitySeverity, VulnerabilityStatus,
@@ -345,6 +421,7 @@ def upsert_m365_vulnerability(
existing.package_name = product_name[:255]
existing.package_version = installed_version[:100]
existing.fixed_version = (fixed_build or None)
_attach_reference(existing, reference_url)
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="Microsoft 365 Apps check reports this CVE again", source="m365_check")
existing.detected_at = datetime.now()
@@ -369,6 +446,7 @@ def upsert_m365_vulnerability(
sources='["microsoft365-apps"]',
first_detected_by="m365_check",
)
_attach_reference(vuln, reference_url)
db.add(vuln)
db.flush()
try:
@@ -502,6 +580,7 @@ def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict:
product_name=name,
installed_version=version,
fixed_build=result["latest_build"],
reference_url=_reference_for(result, cve_id),
)
stats["cve_findings_total"] += 1
touched_cves.add(cve_id.upper())
@@ -662,6 +741,7 @@ def run_m365_for_packages(db: Session, asset, packages: list) -> int:
upsert_m365_vulnerability(
db, asset_id=asset.id, cve_id=cve_id, product_name=name,
installed_version=version, fixed_build=result["latest_build"],
reference_url=_reference_for(result, cve_id),
)
count += 1
touched.add(cve_id.upper())
+62 -10
View File
@@ -45,6 +45,12 @@ SYNTH_DESC_PREFIX = "MSRC reports "
# night (an app-scan + MSRC refresh still produced no Edge findings).
_INDEX_SETTING = "msrc_product_index_v2"
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# How old the index may be for a SCAN to decide from it. The nightly chain
# rebuilds at 01:30 and every scan that follows reuses that build; a scan
# started by hand hours later rebuilds first, because the whole point of
# pressing the button is to see today's CVEs and not last night's (see
# ensure_index). 6h keeps the nightly 01:30 → 03:20 → 03:50 chain on one build.
SCAN_INDEX_MAX_AGE = timedelta(hours=6)
# Curated map: asset side (`match_re`) → MSRC ProductTree name (`msrc_re`).
#
@@ -275,7 +281,8 @@ def _store_index(db: Session, index: dict) -> None:
db.commit()
def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
def _load_blob(db: Session) -> Optional[tuple]:
"""(index, built_at) as stored, or None when there is no usable cache."""
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
if not row or not row.value:
@@ -285,9 +292,47 @@ def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
built = datetime.fromisoformat(blob.get("built_at"))
except Exception:
return None
return (blob.get("index") or {}), built
def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
got = _load_blob(db)
if not got:
return None
index, built = got
if not allow_stale and datetime.now() - built > _INDEX_TTL:
return None
return blob.get("index") or {}
return index
def ensure_index(db: Session, max_age: timedelta = _INDEX_TTL) -> dict:
"""The index, rebuilt first when it is missing or older than `max_age`.
A failed rebuild keeps the cached one an offline night is no worse than
before and is logged at warning level, because a build that fails
quietly leaves every consumer deciding from an old index for as long as
it keeps failing (load_index serves it regardless of age).
Why the age matters: the index is what the app scan's package pass creates
the Edge findings from, and MSRC publishes Edge CVEs on any weekday. An
index rebuilt AFTER that scan (03:50 vs 03:20) put every Edge CVE a full
night late published day D, on the host D+2 (observed: CVE-2026-84324,
published 03.09. 17:00, still "app-scan only" on 04.09.). The 01:30 index
refresh rebuilds it now; this helper lets the 03:50 job reuse that build
instead of pulling the 18 documents a second time.
"""
got = _load_blob(db)
if got and datetime.now() - got[1] <= max_age:
return got[0]
try:
fresh = build_product_index(db)
except Exception as e:
if got:
logger.warning("msrc-scan: index rebuild failed, serving the cached one "
"from %s: %s", got[1].isoformat(timespec="minutes"), e)
return got[0]
raise
return fresh or (got[0] if got else {})
# ---------- scan ----------
@@ -662,22 +707,28 @@ def run_msrc_scan(db: Session, asset_id: Optional[int] = None) -> dict:
from app.models.asset import Asset
stats = {"assets": 0, "findings": 0, "new": 0, "resolved": 0, "errors": []}
index = load_index(db)
if not index:
logger.info("msrc-scan: index missing — building now (one-time, then nightly)")
try:
index = build_product_index(db) or {}
except Exception as e:
stats["errors"].append(f"index build failed: {e}")
return stats
# Not load_index(): that serves the stored blob at ANY age, so a scan run
# by hand decided from whenever the last nightly build happened and could
# never surface a CVE published since. A failed rebuild still serves the
# cache, so an offline MSRC costs nothing this run.
try:
index = ensure_index(db, max_age=SCAN_INDEX_MAX_AGE) or {}
except Exception as e:
stats["errors"].append(f"index build failed: {e}")
return stats
if not index:
return stats
from app.services.audit_events import asset_inventory, use_inventory
new_ids: list = []
q = db.query(Asset)
if asset_id is not None:
q = q.filter(Asset.id == asset_id)
for asset in q.all():
# This pass compares asset.os_version, which an inventory sync wrote —
# so a reopen from it inherits that inventory's lag, not "live".
use_inventory(asset_inventory(asset))
prod = resolve_os(asset.operating_system or "")
if not prod:
continue
@@ -693,6 +744,7 @@ def run_msrc_scan(db: Session, asset_id: Optional[int] = None) -> dict:
except Exception as e:
db.rollback()
stats["errors"].append(f"asset {asset.id}: {e}")
use_inventory(None)
stats["new"] = len(new_ids)
if new_ids:
+32 -1
View File
@@ -205,6 +205,34 @@ def _backfill_description(db: Session, cve_id: str, text: str) -> int:
return n
_MS_SEVERITY_RANK = {"low": 1, "moderate": 2, "important": 3, "critical": 4}
def _max_ms_severity(words) -> Optional[str]:
"""Highest word on Microsoft's scale, title-cased — what the Update Guide
shows as "Max Severity". Anything off the scale is ignored."""
best = max((str(w or "").strip().lower() for w in words),
key=lambda w: _MS_SEVERITY_RANK.get(w, 0), default="")
return best.title() if best in _MS_SEVERITY_RANK else None
def _cvrf_ms_severity(v: dict) -> Optional[str]:
"""CVRF Threats of Type 3 carry the severity, one per product group."""
return _max_ms_severity(
(t.get("Description") or {}).get("Value")
for t in v.get("Threats") or [] if t.get("Type") == 3)
def _set_ms_severity(db: Session, cve_id: str, rating: Optional[str]) -> int:
"""Stamp Microsoft's rating on every finding for the CVE, whichever
scanner found it. Follows revisions both ways."""
if not rating:
return 0
from app.models.vulnerability import Vulnerability
return sum(v.set_vendor_severity("Microsoft", rating)
for v in db.query(Vulnerability).filter(Vulnerability.cve_id == cve_id).all())
def _upsert_cve(db: Session, cve_id: str, rows: List[dict]) -> None:
"""Replace all msrc rows for a cve_id with the freshly parsed set."""
db.query(CveRemediation).filter(
@@ -241,7 +269,7 @@ def refresh_msrc(db: Session, months_back: Optional[int] = None,
}
stats = {"docs": 0, "cves_seen": 0, "cves_stored": 0, "rows": 0,
"descriptions": 0, "errors": []}
"descriptions": 0, "ms_severity": 0, "errors": []}
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as client:
try:
@@ -271,6 +299,7 @@ def refresh_msrc(db: Session, months_back: Optional[int] = None,
desc = _cve_description(v)
if desc:
stats["descriptions"] += _backfill_description(db, cve, desc)
stats["ms_severity"] += _set_ms_severity(db, cve, _cvrf_ms_severity(v))
rows = parse_vulnerability(v)
if not rows:
continue
@@ -328,8 +357,10 @@ def enrich_cve_msrc(db: Session, cve_id: str) -> int:
logger.debug("MSRC on-demand fetch failed for %s: %s", cve_id, e)
return 0
_set_ms_severity(db, cve_id, _max_ms_severity(p.get("severity") for p in products))
rows = rows_from_sug(products)
if not rows:
db.commit()
_NOT_IN_MSRC.add(cve_id)
return 0
_upsert_cve(db, cve_id, rows)
+16 -16
View File
@@ -31,6 +31,7 @@ from sqlalchemy.orm import Session
from app.integrations.nessus_client import NessusClient
from app.models.asset import Asset, AssetSource, AssetStatus
from app.services.asset_matching import match_by_hostname, match_by_ip
from app.models.scan import Scan, ScanStatus, ScanType
from app.models.setting import Setting
from app.models.vulnerability import (
@@ -112,11 +113,8 @@ def _find_or_create_asset(
if short and short != hostname:
candidates.append(short)
for candidate in candidates:
a = (
db.query(Asset)
.filter(Asset.hostname.ilike(candidate))
.first()
)
a = match_by_hostname(db, candidate, pin_col=Asset.nessus_host_uuid,
pin_value=host_uuid, ip=ip)
if a:
if host_uuid and not a.nessus_host_uuid:
a.nessus_host_uuid = host_uuid
@@ -128,11 +126,8 @@ def _find_or_create_asset(
# assets whose hostname starts with 'short.' so we still
# consolidate the right way around.
if short:
a = (
db.query(Asset)
.filter(Asset.hostname.ilike(f"{short}.%"))
.first()
)
a = match_by_hostname(db, f"{short}.%", pin_col=Asset.nessus_host_uuid,
pin_value=host_uuid, ip=ip)
if a:
if host_uuid and not a.nessus_host_uuid:
a.nessus_host_uuid = host_uuid
@@ -142,7 +137,7 @@ def _find_or_create_asset(
# 3) IP
if ip:
a = db.query(Asset).filter(Asset.ip_address == ip).first()
a = match_by_ip(db, ip, hostname)
if a:
if host_uuid and not a.nessus_host_uuid:
a.nessus_host_uuid = host_uuid
@@ -165,6 +160,8 @@ def _find_or_create_asset(
"Nessus sync: auto-created asset %s (from FQDN '%s', ip %s)",
a.hostname, hostname, a.ip_address,
)
from app.services.asset_lifecycle import created
created(db, a, "Nessus")
return a, "created"
return None, "skipped"
@@ -714,11 +711,14 @@ def run_nessus_sync(
):
existing.description = full_description
changed = True
# references: append Nessus see_also URLs if the
# column is empty.
if n_see_also and not existing.references:
existing.references = json.dumps(n_see_also)
changed = True
# references: add Nessus see_also URLs. Merged,
# not fill-only: other sources write this column
# too (the M365 check attaches its release-notes
# section), and a non-empty column must not cost
# the host its vendor advisory.
for _url in (n_see_also or []):
if existing.add_reference(_url):
changed = True
# Remediation precedence: a SPECIFIC solution
# always beats a generic/empty one; a generic
# solution never overwrites a specific one. Fixes
+850
View File
@@ -0,0 +1,850 @@
"""Netdisco inventory sync.
Registers every device Netdisco has discovered as an asset (source=NETDISCO),
then runs CVE detection on the ones whose firmware line is covered HPE Aruba,
Cisco IOS XE / IOS XR, Extreme Networks EXOS, Ubiquiti UniFi access points and
Brocade Fabric OS the same find-or-create + lifecycle-reconcile pattern as the
Intune, Nessus, vCenter and IGEL syncs. NetScaler ADC/Gateway is the sixth line,
Kemp LoadMaster the seventh, Sophos Firewall the eighth.
Why this exists: a switch is the one asset class nothing else in this dashboard
can see. It runs no Wazuh agent (you do not install one on switch firmware),
no MDM enrols it, and a Nessus scan without SNMP credentials gets an open SSH
port and no version. So an estate of access switches the devices every other
asset is plugged into showed up as zero assets and zero findings; not
"clean", just unlooked-at. Netdisco already holds the exact inventory needed:
it asks each device over SNMP and records the firmware version.
The firmware version is the point, and each vendor needs its own
normalisation. HPE prefixes
the build with a two-letter CODE LINE naming the hardware family the image is
for "WC.16.11.0016" on a 2930F, "YA.16.11.0027" on a 2530, "PL.10.13.1005"
on a CX 6300. The letters are not part of the version; HPE's own advisories
bound the numbers only. See cvelistv5_scan_service.aruba_version.
Cisco is the opposite case: its trailing letters ARE the version. "17.15.4c" is
a different release from "17.15.4", and both sources enumerate them as separate
affected entries, so the Cisco match is exact string membership and never a
comparison. All that is normalised there is case and zero-padding Cisco's own
release notes write one build as both "17.09.05a" and "17.9.5a", and only the
second spelling exists in NVD and cvelistV5. See
cvelistv5_scan_service.cisco_version and scan_asset_cisco.
Extreme is the plain case of the three: EXOS versions are dotted numbers and
Extreme states proper floored ranges for them, so the only normalisation is
dropping what SNMP::Info read them out of the sysDescr sentence, and the
"-patch1-11" tag a patch build carries, which neither source ever states as a
bound. See cvelistv5_scan_service.extreme_version and scan_asset_extreme.
Ubiquiti is the case where the os slug is worthless: a UniFi row answers it as
"U6-LR", "U7-Outdoor" or plain "UniFi" depending on what sysDescr said. The
MODEL is the column that decides, and it decides twice once whether the row
is an access point at all (AP, switch and gateway firmware share one numbering
scheme, so a USW judged against AP bounds is a finding on the wrong host), and
once which records apply, because half of Ubiquiti's records name a single
model. The model therefore rides in the asset's OS string. See
ubiquiti_family() and cvelistv5_scan_service.scan_asset_ubiquiti.
Brocade is the only line covered from NVD ALONE. Its CNA writes the affected
versions as an English sentence "before 9.2.1c3, and 9.2.2 though 9.2.2b"
which no range collector can read, while NVD's analysts turn the same prose
into proper cpeMatch ranges. Fabric OS also letters its rebuilds ("9.0.1e1",
"8.2.3e") and, unlike Cisco, states real RANGES over them, so the letters have
to be ordered rather than compared for equality. See brocade_family() and
app_cve_scanner_service._vparse.
NetScaler writes a release line and a build, "14.1-72.61", and the CNA bounds
the build within the line. Its FIPS and NDcPP builds are separate lines that the
inventory cannot tell apart, so those build ranges get no verdict. See
netscaler_family() and cvelistv5_scan_service.scan_asset_netscaler.
LoadMaster reads "7.2.63.3.23e1735.RELEASE.20260625-1143"; the first four numbers
are the release. Its GA and long-term lines share one numbering and every advisory
names a fix per line, so the decision is per line. See loadmaster_family() and
cvelistv5_scan_service.scan_asset_loadmaster.
Sophos Firewall reads "22.0.1 MR-1-Build490". Both sources bound the MR, but
Sophos hotfixes older releases in place without changing that string, so the
releases each advisory hotfixed are held in code and get no verdict. See
sophos_family() and cvelistv5_scan_service.scan_asset_sophos.
What is NOT here, deliberately:
* Vendor security bulletins. HPE publishes its own (hpesbnw*), and they are
the same data the CNA records already carry in structured form the two
CVE sources cover the current advisories between them, so a bulletin
scraper would add a third opinion and no new CVE. If a future advisory ever
turns out to exist nowhere else, that is when to add one.
* End-of-maintenance dates. EOM is the finding that matters most for a switch
a firmware line that no longer receives security fixes cannot be patched
out of anything but HPE publishes those dates per PRODUCT (the hardware),
in a support-lifecycle portal, and nowhere machine-readable per firmware
line. endoflife.date carries no ArubaOS product at all. An invented date on
a switch estate would retire real assets on a guess, so nothing is claimed
until a source that states EOM per OS release is found.
* Devices outside the covered firmware lines get an asset, an OS string and
no CVE verdict. Cumulus, NX-OS and the rest need their own curated
matching; inventing one from a generic version comparison is how a scanner
produces a page of findings that are all wrong.
* Extreme's Fabric Engine (VOSS). Both sources carry its CVEs, but no
Netdisco row can be told apart from an Avaya/Nortel ERS switch see
extreme_family() for why, and why guessing would flag the whole ERS
estate.
* Ubiquiti's UniFi switches, gateways and cameras. NVD carries a separate
`unifi_switch_firmware` CPE and Ubiquiti a separate "UniFi Switches"
product, so the data is there but no such device was in the estate this
was built against, and a key nothing fills is a key nothing tests. The
model gate in ubiquiti_family() already tells them apart; adding the key is
a few lines the day one appears.
* Brocade's IP switches (ICX/FastIron, NetIron). Same vendor string, same
dotted-plus-letter version shape, completely different firmware kept out
by the os slug, which is the only thing that separates them.
* Classic Cisco IOS (12.x/15.x). It numbers releases "15.2(7)E3" NVD
backslash-escapes the parentheses into the CPE and no version scheme here
reads that. IOS XE and IOS XR are covered; a classic-IOS device gets the
asset only.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetSource, AssetStatus
from app.services.asset_matching import (different_hosts, match_by_hostname, match_by_ip,
stable_serial as _stable_serial)
from app.services import cvelistv5_scan_service as c5
logger = logging.getLogger(__name__)
SETTING_KEY = "netdisco_config"
# ------------------------------------------------------------------
# Which Aruba family a Netdisco row is
# ------------------------------------------------------------------
# Netdisco's `os` column is SNMP::Info's os() — a slug, not a product name:
# arubaos-cx SNMP::Info::Layer3::ArubaCX → AOS-CX (6000/6300/8300 …)
# hp SNMP::Info::Layer2::HP → the ProVision line (2530, 2930F,
# 3810, 5400R) — "ArubaOS-Switch"
# aos-w/airos SNMP::Info::Layer3::Aruba → Mobility controllers, ArubaOS
# so the family cannot be read off the string alone: "hp" is also what a
# non-switch HP device answers, and "airos" is what Ubiquiti's AirOS is called
# too. Each family therefore has to agree on THREE things — the os slug, the
# vendor, and the shape of the version — before anything is claimed.
_CX_OS_RE = re.compile(r"^(arubaos|aos)[-_ ]?cx$|^aruba[-_ ]?cx$", re.I)
_AOSS_OS_RE = re.compile(r"^(hp|hpe|procurve|hp[-_ ]?procurve|"
r"(arubaos|aos)[-_ ]?s(witch)?)$", re.I)
_AOSW_OS_RE = re.compile(r"^(aos[-_ ]?w|airos|arubaos|aruba)$", re.I)
# Vendor, as Netdisco spells it (SNMP::Info's vendor(), lowercase slug).
_ARUBA_VENDORS = {"aruba", "arubanetworks", "aruba networks", "hp", "hpe",
"hewlett packard", "hewlett-packard",
"hewlett packard enterprise", "alcatel-lucent"}
# ProVision majors. 15.x and 16.x are the lines HPE bounds its AOS-S advisories
# by and the only ones NVD carries versions for; anything older is a ProCurve
# from before the numbering (H.10.98) that no current advisory mentions. The
# check is what keeps a non-switch "hp" device — which answers the same os slug
# with some other version scheme entirely — out of the switch family.
_AOSS_MAJORS = {15, 16}
def aruba_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
"""Netdisco (os, vendor, version) → curated Aruba product key, or None.
None means "not an Aruba device we can decide", and it is the answer for
everything this connector cannot match precisely that asset still exists,
it simply carries no CVE verdict, which is the honest state.
"""
os_slug = (os_slug or "").strip()
vendor_lc = (vendor or "").strip().lower()
num = c5.aruba_version(version)
parts = c5.cpe._vtuple(num) if num else None
if not parts or len(parts) < 2:
return None
if _CX_OS_RE.match(os_slug):
# AOS-CX writes three components, "10.13.1005". A two-component answer
# is a truncated read, not a version we can bound.
return "aruba-cx" if len(parts) >= 3 else None
if _AOSS_OS_RE.match(os_slug) and vendor_lc in _ARUBA_VENDORS:
return ("aruba-switch"
if parts[0] in _AOSS_MAJORS and len(parts) >= 3 else None)
if _AOSW_OS_RE.match(os_slug) and vendor_lc in _ARUBA_VENDORS:
# Mobility AOS writes four components, "8.13.1.1" / "10.7.2.2". The
# count is what separates a controller on 10.7.2.2 from a CX switch on
# 10.13.1005 if the os slug is ever ambiguous — and it is what keeps a
# Ubiquiti AirOS device (same "airos" slug, another vendor entirely)
# from being read as an Aruba controller.
return "aruba-os" if len(parts) >= 4 else None
return None
# ------------------------------------------------------------------
# Which Cisco firmware line a Netdisco row is
# ------------------------------------------------------------------
# SNMP::Info's os() slug again, and the same three-way agreement as Aruba —
# slug, vendor and the shape of the version — before anything is claimed.
# ios-xe SNMP::Info::Layer3::CiscoSwitch etc. → IOS XE (Catalyst 9k, ISR,
# ASR 1k, C1121X …)
# ios-xr SNMP::Info::Layer3::CiscoXR → IOS XR (ASR 9k, NCS)
# ios classic IOS — NOT covered, see the module docstring
_XE_OS_RE = re.compile(r"^ios[-_ ]?xe$", re.I)
_XR_OS_RE = re.compile(r"^ios[-_ ]?xr$", re.I)
_CISCO_VENDORS = {"cisco", "cisco systems", "ciscosystems"}
def cisco_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
"""Netdisco (os, vendor, version) → curated Cisco product key, or None.
The version must carry at least three numeric components, which is what
every IOS XE and IOS XR release has ever had ("17.15.4c", "3.2.0SG",
"7.11.2"). A two-component answer is a truncated sysDescr read, and since
the match downstream is EXACT it would simply never hit better to say
"no verdict" here than to scan with a release that cannot exist.
"""
os_slug = (os_slug or "").strip()
vendor_lc = (vendor or "").strip().lower()
if vendor_lc not in _CISCO_VENDORS:
return None
num = c5.cisco_version(version)
parts = c5.cpe._vtuple(num) if num else None
if not parts or len(parts) < 3:
return None
if _XE_OS_RE.match(os_slug):
return "cisco-ios-xe"
if _XR_OS_RE.match(os_slug):
return "cisco-ios-xr"
return None
# ------------------------------------------------------------------
# Which Extreme firmware line a Netdisco row is
# ------------------------------------------------------------------
# SNMP::Info::Layer3::Extreme answers vendor() as the literal "extreme" and
# os() from sysDescr — "xos" when the description mentions XOS, "extremeware"
# otherwise. Only the first is covered here; ExtremeWare is the 2010-era
# firmware of the same company and a different product line, and NVD's
# `extremeware_xos` bounds stop at 11.1.
#
# Fabric Engine (VOSS) is NOT reachable from this connector, and that is an
# INVENTORY limit rather than a data one — both sources carry its CVEs
# (CVE-2025-11192 has a CPE and a CNA range). SNMP::Info has no VOSS class, so
# a VSP is classed as Layer3::Passport and answers os "passport", vendor
# "avaya" — precisely what an Avaya/Nortel ERS switch answers, whose 5.9.x
# firmware is not Fabric Engine at all. Nothing else on the row separates them,
# and "before 9.3" handed to an ERS numbered 5.x matches every one of them. The
# day a row can be told apart, the key is a few lines.
_EXOS_OS_RE = re.compile(r"^(x|e|extreme\s*)?xos$", re.I)
_EXTREME_VENDORS = {"extreme", "extreme networks", "extremenetworks"}
def extreme_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
"""Netdisco (os, vendor, version) → the curated EXOS key, or None.
Three-way agreement again slug, vendor and the shape of the version. The
version must carry at least three numeric components: EXOS writes four
("31.7.2.4", "22.7.1.1-patch1-11") and Extreme's own bounds three or four
("31.7.4", "32.7.4.15"), so a two-component answer is a truncated sysDescr
read. Compared anyway it would sit below every bound in the record and
collect the lot the one direction this connector never guesses in.
"""
if (vendor or "").strip().lower() not in _EXTREME_VENDORS:
return None
if not _EXOS_OS_RE.match((os_slug or "").strip()):
return None
num = c5.extreme_version(version)
parts = c5.cpe._vtuple(num) if num else None
return "extreme-exos" if parts and len(parts) >= 3 else None
# ------------------------------------------------------------------
# Which Ubiquiti UniFi device a Netdisco row is
# ------------------------------------------------------------------
# The one vendor here whose os slug decides NOTHING. SNMP::Info reports a UniFi
# device's os() as whatever sysDescr called it — the estate in the field report
# answered "U6-LR", "U7-Outdoor" and plain "UniFi" across rows of the same
# firmware line — so the slug is sometimes the model, sometimes the brand, and
# never the product line. The MODEL column is what separates the product lines,
# and it has to, because Ubiquiti numbers AP, switch and gateway firmware in
# one shared scheme: CVE-2023-38034 fixes the APs in 6.5.53 and the switches in
# 6.5.32. A USW-24 judged against the AP bounds is a finding on the wrong host.
#
# So: vendor, model, and the shape of the version — the same three-way
# agreement as everywhere else, with model standing in for the slug.
_UBIQUITI_VENDORS = {"ubiquiti", "ubiquiti networks", "ubiquiti inc",
"ubnt", "ui", "ui.com"}
# Access-point models, and only those. Ubiquiti's AP range is "UAP-*" (the
# older AC generation), "U6-*"/"U7-*" (WiFi 6/6E/7) and the two bridges,
# UBB and UWB-XG. `u\d` rather than a fixed list of generations so a U8 or U9
# is covered the day it ships — nothing else Ubiquiti makes starts with a
# letter-then-digit: switches are "US-*"/"USW-*", gateways "UDM"/"USG"/"UXG",
# cameras "UVC-*"/"G4-*", and none of them match.
_UAP_MODEL_RE = re.compile(r"^(uap|ubb|uwb|u\d+)([-+ ]|$)", re.I)
def ubiquiti_family(vendor: str, model: str, version: str) -> Optional[str]:
"""Netdisco (vendor, model, version) → the curated UniFi AP key, or None.
Three numeric components required, which every UniFi firmware release has
("6.7.58", "8.8.7") and every bound in both sources is written as. A
two-component answer is a truncated read, and compared anyway it would sit
below every bound in the record and collect the lot.
"""
if (vendor or "").strip().lower() not in _UBIQUITI_VENDORS:
return None
if not _UAP_MODEL_RE.match((model or "").strip()):
return None
parts = c5.cpe._vtuple(c5.cpe._clean_version(version or "") or "")
return "ubiquiti-uap" if parts and len(parts) >= 3 else None
# ------------------------------------------------------------------
# Which Brocade firmware line a Netdisco row is
# ------------------------------------------------------------------
# Here the os slug is load-bearing again, and it is the ONLY thing that keeps
# two unrelated firmware lines apart. Brocade sells FC SAN switches running
# Fabric OS and IP switches running FastIron/NetIron, both under the same
# vendor string and both numbered in the same dotted-plus-letter shape
# ("9.0.1e1" against "08.0.95hT211"). SNMP::Info classes them separately —
# Layer2::Brocade answers os "brocade" for the FC switches, the Foundry classes
# answer "ironware"/"fastiron"/"foundry" for the IP ones — so the slug is what
# stops a Fabric OS bound being handed to an ICX.
_FABOS_OS_RE = re.compile(r"^(brocade|fab(ric)?[-_ ]?os)$", re.I)
_BROCADE_VENDORS = {"brocade", "broadcom", "brocade communications systems",
"brocade communications"}
# The canonical label side of the same question, for an asset that already
# exists. Brocade has no cvelistV5 key to borrow one from — its CNA writes the
# bounds as English prose that no range collector can read — so this line lives
# here rather than in cvelistv5_scan_service.
FABOS_LABEL = "Brocade Fabric OS"
_FABOS_LABEL_RE = re.compile(r"^(brocade\s+|broadcom\s+)?fabric\s*os\b", re.I)
def brocade_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
"""Netdisco (os, vendor, version) → the curated Fabric OS key, or None.
The version is read with the LETTERED reader, not the strict one: Fabric OS
numbers its rebuilds "9.0.1e1", "8.2.3e", "9.2.1c3" and both NVD's bounds
and Brocade's own prose are written the same way. Read strictly the version
is not a version at all, which would mean no scan.
"""
if (vendor or "").strip().lower() not in _BROCADE_VENDORS:
return None
if not _FABOS_OS_RE.match((os_slug or "").strip()):
return None
num = c5.cpe._lettered_version(version or "")
parts = c5.cpe._vtuple(num) if num else None
return "brocade-fabricos" if parts and len(parts) >= 3 else None
# ------------------------------------------------------------------
# Which NetScaler a Netdisco row is
# ------------------------------------------------------------------
# Observed row: vendor "citrix", os "netscaler", model "Netscaler Remote
# Licensed Virtual Appliance", version "14.1-72.61". Slug, vendor and the shape
# of the version again. A model or version naming FIPS or NDcPP is withheld
# outright; the build-number guard in netscaler_build covers the rows that do
# not say so.
_NETSCALER_OS_RE = re.compile(r"^netscaler([-_ ]?(adc|gateway))?$", re.I)
_NETSCALER_VENDORS = {"citrix", "citrix systems", "netscaler", "cloud software group"}
_FIPS_RE = re.compile(r"fips|ndcpp", re.I)
def netscaler_family(os_slug: str, vendor: str, model: str, version: str) -> Optional[str]:
"""Netdisco (os, vendor, model, version) → the curated NetScaler key, or None."""
if (vendor or "").strip().lower() not in _NETSCALER_VENDORS:
return None
if not _NETSCALER_OS_RE.match((os_slug or "").strip()):
return None
if _FIPS_RE.search(f"{model or ''} {version or ''}"):
return None
return "netscaler-adc" if c5.netscaler_build(version) else None
# ------------------------------------------------------------------
# Which LoadMaster a Netdisco row is
# ------------------------------------------------------------------
# Observed rows (enterprise 12196): vendor "Kemp", os "LoadMaster" before
# Netdisco had a LoadMaster device class; vendor "kemp", os "LMOS" (LoadMaster
# OS) since. Model "LoadMaster" and version
# "7.2.63.3.23e1735.RELEASE.20260625-1143" in both.
_LOADMASTER_VENDORS = {"kemp", "kemp technologies", "progress", "progress software"}
_LOADMASTER_OS_SLUGS = {"loadmaster", "lmos"}
def loadmaster_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
"""Netdisco (os, vendor, version) → the curated LoadMaster key, or None."""
if (vendor or "").strip().lower() not in _LOADMASTER_VENDORS:
return None
if (os_slug or "").strip().lower() not in _LOADMASTER_OS_SLUGS:
return None
return "kemp-loadmaster" if c5.loadmaster_version(version) else None
# ------------------------------------------------------------------
# Which Sophos Firewall a Netdisco row is
# ------------------------------------------------------------------
# Observed row: vendor "sophos", os "sfos", model "XGS…", version
# "22.0.1 MR-1-Build490".
def sophos_family(os_slug: str, vendor: str, version: str) -> Optional[str]:
"""Netdisco (os, vendor, version) → the curated Sophos Firewall key, or None."""
if (vendor or "").strip().lower() != "sophos" or (os_slug or "").strip().lower() != "sfos":
return None
return "sophos-firewall" if c5.sophos_version(version) else None
def firmware_family(d: dict) -> Optional[str]:
"""The curated product key for a device row, across every covered vendor.
One place to ask "can this device get a CVE verdict at all?", so the sync,
the connection test and the stats do not each carry their own list.
"""
args = (d.get("os", ""), d.get("vendor", ""), d.get("os_version", ""))
return (aruba_family(*args) or cisco_family(*args)
or extreme_family(*args) or brocade_family(*args)
or ubiquiti_family(d.get("vendor", ""), d.get("model", ""),
d.get("os_version", ""))
or netscaler_family(d.get("os", ""), d.get("vendor", ""),
d.get("model", ""), d.get("os_version", ""))
or loadmaster_family(*args) or sophos_family(*args))
def os_and_version(d: dict) -> tuple:
"""Device row → (operating_system, os_version) to write onto the asset.
For a device on a covered firmware line that is the canonical family label
and the NORMALISED version both CVE paths key off exactly those, and
neither "WC.16.11.0016" nor "17.09.05A" is a string either source states.
For everything else it is Netdisco's own two slugs, joined: "cisco ios",
"cumulus linux". Joined and not bare, because a bare "ios" is a product
name that belongs to somebody else see the anchored Apple entry in
app_cve_scanner_service._OS_REGISTRY. Note that a classic-IOS device lands
here, on purpose: "cisco ios" matches neither the Apple pattern nor the
anchored "cisco ios xe"/"cisco ios xr" ones, so it gets an asset and no
verdict.
"""
key = aruba_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", ""))
if key:
return c5.ARUBA_LABELS[key], c5.aruba_version(d.get("os_version"))
if extreme_family(d.get("os", ""), d.get("vendor", ""),
d.get("os_version", "")):
# The plain release, without the sentence SNMP::Info may have read it
# out of and without a "-patch1-11" tag neither source states.
return c5.EXTREME_EXOS_LABEL, c5.extreme_version(d.get("os_version"))
key = cisco_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", ""))
if key:
# Normalised to the spelling BOTH sources use — lowercased, no
# zero-padding — because the Cisco match is exact and "17.09.05A" and
# "17.9.5a" are the same release written two ways.
return c5.CISCO_LABELS[key], c5.cisco_version(d.get("os_version"))
if brocade_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", "")):
# The lettered reader, not the strict one — the rebuild letter is part
# of the Fabric OS release and every bound is written with it.
return (FABOS_LABEL, c5.cpe._lettered_version(d.get("os_version") or ""))
if ubiquiti_family(d.get("vendor", ""), d.get("model", ""),
d.get("os_version", "")):
# The MODEL is carried in the label, and it is not decoration: half of
# Ubiquiti's AP records name one model and apply to that model alone
# (CVE-2024-37380 is U6+ only). scan_asset_ubiquiti reads it back out.
return (f"{c5.UBIQUITI_UAP_LABEL} {(d.get('model') or '').strip()}".strip(),
c5.cpe._clean_version(d.get("os_version") or ""))
if netscaler_family(d.get("os", ""), d.get("vendor", ""), d.get("model", ""),
d.get("os_version", "")):
# The build as written: both sources bound "14.1-72.61" in that shape.
return c5.NETSCALER_LABEL, (d.get("os_version") or "").strip()
if loadmaster_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", "")):
# The release only; neither source bounds the build hash or date.
return c5.KEMP_LOADMASTER_LABEL, c5.loadmaster_version(d.get("os_version"))
if sophos_family(d.get("os", ""), d.get("vendor", ""), d.get("os_version", "")):
# As written, the way Sophos shows it; both passes normalise it.
return c5.SOPHOS_FIREWALL_LABEL, (d.get("os_version") or "").strip()
os_slug = (d.get("os") or "").strip()
vendor = (d.get("vendor") or "").strip()
if os_slug and vendor and vendor.lower() != os_slug.lower():
label = f"{vendor} {os_slug}"
else:
label = os_slug or vendor
return (label or None), ((d.get("os_version") or "").strip() or None)
def load_netdisco_config(db: Session) -> Optional[dict]:
"""Decrypt + parse netdisco_config, or None when not configured.
Only the host is required. Netdisco's own demo answers the inventory report
with no authentication at all, and an instance behind a reverse proxy that
authenticates for it is a real deployment too refusing to run without a
credential would lock both out. Production instances should still set an
API key; the settings card says so.
"""
from app.auth.setting_crypto import read_setting_value
raw = read_setting_value(db, SETTING_KEY)
if not raw:
return None
try:
cfg = json.loads(raw)
except json.JSONDecodeError:
logger.warning("netdisco_config is not valid JSON")
return None
return cfg if cfg.get("host") else None
def _build_client(cfg: dict):
from app.integrations.netdisco_client import NetdiscoClient, DEFAULT_PORT
return NetdiscoClient(
host=cfg["host"],
api_key=cfg.get("api_key") or "",
username=cfg.get("username") or "",
password=cfg.get("password") or "",
port=int(cfg.get("port") or DEFAULT_PORT),
use_https=bool(cfg.get("use_https", False)),
verify_ssl=cfg.get("verify_ssl", True),
)
# Cross-process guard, same reasoning as the vCenter and IGEL syncs: the
# nightly job and a manual trigger run in different contexts and would update
# the same asset rows in different orders.
_SYNC_ADVISORY_LOCK_KEY = 0x54560104 # "TV" + 04
# A thin client IGEL UMS manages is never a Netdisco device: a name or address
# shared with one is another device (field report 2026-09-15).
_NOT_NETDISCO = (Asset.igel_unit_id,)
def _other_hardware(a: Asset, serial: Optional[str], name: str) -> bool:
"""A row pinned to this address that belongs to a different box.
The address pin outlives the device it was set for: an AP that moved, a
fork from before 053, a switch pulled out of Netdisco. The next device to
get that address used to take the row its name, its status, the old
device's findings (field report 2026-09-15, a Windows server still carrying
a UniFi AP's address). A device that reports a serial the row does not
carry, under a different name, is new hardware. Both are needed: stack
members share the name and differ in serial, a renamed switch without a
serial differs only in name both stay on the row.
"""
return bool(serial) and a.netdisco_serial != serial and different_hosts(name, a.hostname)
def _find_or_create_asset(db: Session, d: dict, auto_create: bool):
"""Match a Netdisco device to an asset by serial, then canonical IP, then name.
The canonical address is what Netdisco itself keys on and it was the only
pin here for a reason: it is configured statically on a switch's management
interface and survives the renames that a switch does get ("sw-12"
"sw-floor2-a"). But it is only ever a stand-in for the identity. Field
report 2026-09-10: an access point updated its firmware, took a new DHCP
lease with it, and forked a second asset same name, same serial while
the reconcile put the old row INACTIVE. The hostname fallback could not
catch it either: it is pinned on `netdisco_device_ip`, so the asset holding
the OLD address was filtered out of exactly the lookup meant to rescue it.
The serial goes first because it is the one value that survives both a
rename and a re-address. It is pinned once and then left alone: stack
members (VSF/IRF) report one name and one address per chassis but a serial
EACH, and they are one logical device the address is what holds them
together, so the asset keeps the first serial it saw and the others match
on the address as before.
"""
ip = (d.get("ip") or "").strip()
name = (d.get("name") or "").strip()
serial = _stable_serial(d.get("serial"))
# A device that answers no name is identified by its canonical address —
# the WHOLE address. `.split(".")[0]` is the short form of a hostname; on
# an address it leaves the first octet, which is both a nonsense asset name
# ("192") and one that every other nameless device in that /8 then matches
# on, merging unrelated devices onto a single asset.
hostname = name or ip
short = name.split(".")[0] if name else ""
def _pin(a):
if ip and a.netdisco_device_ip != ip:
a.netdisco_device_ip = ip
# Never overwritten: on a stack this would flip between chassis on
# every row, and on every device it would let a later bad read take
# the identity away from a good one.
if serial and not a.netdisco_serial:
a.netdisco_serial = serial
if serial:
# limit(2): a serial that two assets carry identifies neither. It
# should not happen — the pin is set once, from this same check — but
# a placeholder that slipped the filter above would show up exactly
# like this, and then the address and the name still decide.
rows = db.query(Asset).filter(Asset.netdisco_serial == serial).limit(2).all()
if len(rows) == 1:
_pin(rows[0])
return rows[0], "netdisco-serial"
if rows:
logger.info("Netdisco sync: serial %s wird von mehreren Assets "
"gefuehrt — kein Identifikator", serial)
if ip:
a = next((a for a in db.query(Asset).filter(Asset.netdisco_device_ip == ip)
.order_by(Asset.id) if not _other_hardware(a, serial, name)), None)
if a:
_pin(a)
# Heal an asset an earlier sync named after the first octet of this
# very address: that name is the fingerprint of the bug above, and
# nothing else legitimately carries it. A device that now answers a
# name is renamed by the caller instead.
if not name and a.hostname == ip.split(".")[0]:
from app.services.asset_lifecycle import rename
rename(db, a, ip[:255], "Netdisco")
return a, "netdisco-ip"
for candidate in [c for c in (hostname, short) if c]:
a = match_by_hostname(db, candidate, pin_col=Asset.netdisco_device_ip,
pin_value=ip, ip=ip, not_pinned_by=_NOT_NETDISCO)
if a:
_pin(a)
return a, "hostname"
if ip:
a = match_by_ip(db, ip, name, not_pinned_by=_NOT_NETDISCO)
if a:
_pin(a)
return a, "ip"
if auto_create and (hostname or ip):
a = Asset(hostname=(short or hostname or ip)[:255], ip_address=ip[:45] or None,
netdisco_device_ip=ip or None, netdisco_serial=serial,
source=AssetSource.NETDISCO, status=AssetStatus.ACTIVE)
db.add(a)
db.flush()
logger.info("Netdisco sync: auto-created asset %s", a.hostname)
from app.services.asset_lifecycle import created
created(db, a, "Netdisco")
return a, "created"
return None, "skipped"
def run_netdisco_sync(db: Session) -> dict:
"""Sync every Netdisco device → assets, then Aruba CVEs."""
cfg = load_netdisco_config(db)
if not cfg:
raise RuntimeError("Netdisco is not configured (settings.netdisco_config missing/incomplete).")
# The lock must NOT ride on `db`: this sync commits, and a committed
# Session gives its connection back to the pool — taking a session-scoped
# lock with it. See database.advisory_lock.
from app.database import advisory_lock
with advisory_lock(_SYNC_ADVISORY_LOCK_KEY) as got:
if not got:
logger.warning("Netdisco sync skipped — another Netdisco sync holds the lock")
return {"skipped": "another sync already running"}
return _run_netdisco_sync_locked(db, cfg)
def _run_netdisco_sync_locked(db: Session, cfg: dict) -> dict:
from app.services.asset_lifecycle import reconcile_netdisco_by_seen_ids, rename
auto_create = bool(cfg.get("auto_create_assets", True))
stats = {"devices": 0, "assets_matched": 0, "assets_created": 0,
"fw_devices": 0, "no_version": 0, "cve_findings": 0,
"assets_inactivated": 0, "assets_reactivated": 0, "errors": []}
seen_asset_ids: set = set()
# Stack-Mitglieder (VSF/IRF) melden sich je Chassis unter demselben Namen
# und derselben Adresse — ein logisches Geraet, eine Firmware, ein CVE-Satz,
# also ein Asset. Unterscheidbar sind sie nur an der Seriennummer, und die
# ueberschriebe sonst jedes Mitglied beim naechsten: {asset_id: [serials]}.
serials: dict = {}
client = _build_client(cfg)
try:
devices = client.get_devices()
except Exception as e:
raise RuntimeError(f"Netdisco inventory fetch failed: {e}") from e
finally:
# Closed here and not after the asset loop: everything below is DB
# work, and the HTTP session has nothing left to do.
client.close()
for d in devices:
stats["devices"] += 1
try:
asset, how = _find_or_create_asset(db, d, auto_create)
if not asset:
continue
if how == "created":
stats["assets_created"] += 1
else:
stats["assets_matched"] += 1
# Matched on a stable identifier — the serial, or the
# canonical address — means the name Netdisco reports now is
# the current one. Switches get renamed; access points too.
name = (d.get("name") or "").strip()
if how in ("netdisco-ip", "netdisco-serial") and name \
and asset.hostname != name:
rename(db, asset, (name.split(".")[0] or name)[:255], "Netdisco")
if d.get("ip"):
asset.ip_address = d["ip"][:45]
os_name, os_version = os_and_version(d)
if os_name:
asset.operating_system = os_name[:255]
if os_version:
asset.os_version = os_version[:100]
else:
# A device Netdisco has discovered but not yet read a version
# from (no SNMP credentials for it, or a discovery run that has
# not reached it). Counted, because "no findings" must not read
# the same as "could not look".
stats["no_version"] += 1
if scan_key(os_name):
stats["fw_devices"] += 1
if d.get("location"):
asset.location = d["location"][:255]
# The raw firmware string lives here: the code-line prefix
# ("WC." / "PL.") is what an operator matches against HPE's download
# page, and os_version now holds the numeric form the scanners need.
serial = (d.get("serial") or "").strip()
if serial and asset.id:
known = serials.setdefault(asset.id, [])
if serial not in known:
known.append(serial)
members = serials.get(asset.id) or ([serial] if serial else [])
desc = "".join(x for x in (
" ".join(y for y in (d.get("vendor"), d.get("model")) if y),
f"firmware {d['os_version']}" if d.get("os_version") else "",
("serials " if len(members) > 1 else "serial ") + ", ".join(members)
if members else "") if x)
asset.description = desc[:1000] or None
asset.last_scan = datetime.now()
asset.last_seen = datetime.now()
asset.last_seen_source = "netdisco"
db.flush()
if asset.id:
seen_asset_ids.add(asset.id)
except Exception as e:
stats["errors"].append(f"device {d.get('name') or d.get('ip')}: {e}")
db.commit()
# CVE pass over the assets this sync touched. Runs after the commit so a
# scan failure cannot lose the inventory we just collected.
c5.take_sophos_no_row() # this run's CVEs only
try:
stats["cve_findings"] = _run_cve_scan(db, seen_asset_ids)
except Exception as e:
stats["errors"].append(f"cve scan: {e}")
no_row = c5.take_sophos_no_row()
if no_row:
stats["errors"].append(no_row)
try:
recon = reconcile_netdisco_by_seen_ids(
db, seen_asset_ids=seen_asset_ids,
reason="not reported by the latest Netdisco sync")
stats["assets_inactivated"] = recon["inactivated"]
stats["assets_reactivated"] = recon["reactivated"]
db.commit()
except Exception as e:
logger.warning("Netdisco reconcile failed: %s", e)
logger.info(
"Netdisco sync done: %d devices, %d matched, %d created, %d with a "
"firmware verdict, %d without a version, %d CVE findings, "
"%d inactivated, %d reactivated",
stats["devices"], stats["assets_matched"], stats["assets_created"],
stats["fw_devices"], stats["no_version"], stats["cve_findings"],
stats["assets_inactivated"], stats["assets_reactivated"])
return stats
def scan_key(os_name: Optional[str]) -> Optional[str]:
"""The curated key for an OS string already written onto an asset, or None.
The asset-side counterpart to firmware_family(): that one reads a Netdisco
row, this one reads the canonical label the row was turned into, which is
all a re-scan of an existing asset has.
"""
return (c5.aruba_key(os_name or "") or c5.cisco_key(os_name or "")
or c5.extreme_key(os_name or "") or c5.ubiquiti_key(os_name or "")
or c5.netscaler_key(os_name or "") or c5.loadmaster_key(os_name or "")
or c5.sophos_key(os_name or "")
or ("brocade-fabricos"
if _FABOS_LABEL_RE.match((os_name or "").strip()) else None))
def _run_cve_scan(db: Session, asset_ids: set) -> int:
"""CVE pass over the assets this sync touched — both sources, every vendor.
The NVD-CPE scan runs FIRST and the cvelistV5 one second, sharing a
`touched` set, for the reason the IGEL sync documents: the cvelistV5 pass
closes what it cannot re-confirm, and each vendor has CVEs that exist only
on the CPE side the three 2023 ArubaOS-Switch flaws (HPE states them as
prose carrying no machine-readable bound), CVE-2019-12660 (Cisco puts the
affected release in the product name) and every EXOS CVE before 2025 (filed
by MITRE with vendor "n/a", so cvelistV5 holds no structured data for them
at all). Run the other way round and
every nightly sync would close them and the next one would reopen them.
"""
if not asset_ids:
return 0
from app.services import app_cve_scanner_service as cpe
index = c5.load_index(db) or {}
if not index:
logger.info("Netdisco sync: no cvelistV5 index yet — CVE pass deferred "
"to the nightly app-CVE scan")
new_ids: list = []
total = 0
for asset in db.query(Asset).filter(Asset.id.in_(asset_ids)).all():
key = scan_key(asset.operating_system)
if not key:
continue # not a family we can decide — see the module docstring
touched: set = set()
try:
total += cpe.scan_asset_os(db, asset, new_ids, touched=touched)
except Exception as e:
logger.warning("Netdisco CPE scan failed for %s: %s", asset.hostname, e)
if not index:
continue
if key == "brocade-fabricos":
# NVD only. Brocade's own records state the bounds as a sentence —
# "before 9.2.1c3, and 9.2.2 though 9.2.2b" — so cvelistV5 carries
# nothing structured to match against. NVD's analysts turn the same
# prose into proper ranges, and the CPE pass above already ran.
continue
pass_fn = (c5.scan_asset_cisco if key.startswith("cisco-")
else c5.scan_asset_extreme if key.startswith("extreme-")
else c5.scan_asset_ubiquiti if key.startswith("ubiquiti-")
else c5.scan_asset_netscaler if key.startswith("netscaler-")
else c5.scan_asset_loadmaster if key.startswith("kemp-")
else c5.scan_asset_sophos if key.startswith("sophos-")
else c5.scan_asset_aruba)
try:
total += pass_fn(db, asset, index, new_ids, touched=touched)
except Exception as e:
logger.warning("Netdisco CNA CVE scan failed for %s: %s", asset.hostname, e)
db.commit()
if new_ids:
# Same tail as the app-CVE and IGEL scans: audit, enrich, notify. A
# finding that never reaches EPSS/KEV enrichment or the new-CVE mail is
# half a finding.
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, new_ids, source="app-scan")
db.commit()
except Exception as e:
logger.debug("Netdisco detected-audit failed: %s", e)
try:
from app.models.vulnerability import Vulnerability
from app.services.enrichment_service import enrich_vulnerabilities
from app.services.email_service import dispatch_new_vuln_notifications
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
if fresh:
enrich_vulnerabilities(db, fresh)
dispatch_new_vuln_notifications(db, fresh)
except Exception as e:
logger.debug("Netdisco enrichment/notify failed: %s", e)
return total
+104
View File
@@ -0,0 +1,104 @@
"""
Patch plan open findings grouped by the product that has to be updated.
The findings list answers "what is wrong"; this answers "what do I patch
first". Each group is one fix action (update product X) with what it removes:
hosts, findings, CVEs, summed priority. Ranking is deterministic and uses only
data the tool already trusts:
1. CVEs exploited in the wild (CISA KEV / ENISA EUVD) any group with one
comes first, most exploited CVEs first
2. summed priority_score the risk the update takes off the table
A finding that names several products (CVE-2026-16417: Chrome AND Edge) counts
toward each: either update is work the operator has to do.
Fixed versions are listed as the scanners reported them, never compared
version order is family-specific and a wrong "latest" would be worse than none.
The Jev hint (jev_config_dependent) is only listed per group so the operator
knows which CVEs to check for a non-default setup; it never changes the rank.
"""
from collections import defaultdict
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetStatus
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
from app.models.vulnerability_package import VulnerabilityPackage
# Open work only: pending_verification is already patched, awaiting a rescan.
_WORK_STATES = (VulnerabilityStatus.open, VulnerabilityStatus.patch_failed)
def open_work(query):
"""Restrict a Vulnerability query to open work on ACTIVE assets.
Shared with jev_triage_service so the hint covers exactly the plan's rows."""
return (query.join(Asset, Asset.id == Vulnerability.asset_id)
.filter(Asset.status == AssetStatus.ACTIVE)
.filter(Vulnerability.status.in_(_WORK_STATES)))
# Jev "noul" (its yes/no answer type: probability of yes) above which the
# CVE is listed as "check your config".
CONFIG_HINT_THRESHOLD = 0.7
def build_patch_plan(db: Session, limit: Optional[int] = None) -> List[dict]:
"""The whole plan in rank order; limit truncates it to the top N."""
rows = open_work(db.query(
Vulnerability.id, Vulnerability.asset_id, Vulnerability.cve_id,
Vulnerability.package_name, Vulnerability.fixed_version,
Vulnerability.priority_score, Vulnerability.kev_listed,
Vulnerability.euvd_listed, Vulnerability.jev_config_dependent,
)).all()
if not rows:
return []
# Per-product detail, one statement for the whole fleet.
packages = defaultdict(list)
for vid, name, fixed in open_work(
db.query(VulnerabilityPackage.vulnerability_id, VulnerabilityPackage.package_name,
VulnerabilityPackage.fixed_version)
.join(Vulnerability, Vulnerability.id == VulnerabilityPackage.vulnerability_id)
):
packages[vid].append((name, fixed))
groups = {}
for r in rows:
# Findings without a product (pseudo-CVEs, OS KBs without a package)
# become a one-CVE action of their own.
for name, fixed in packages.get(r.id) or [(r.package_name or r.cve_id, r.fixed_version)]:
key = name.strip().casefold()
g = groups.setdefault(key, {
"product": name.strip(), "hosts": set(), "findings": 0, "cves": set(),
"fixed": set(), "exploited": set(), "config": set(),
"priority_sum": 0.0, "priority_max": 0.0,
})
prio = r.priority_score or 0.0
g["hosts"].add(r.asset_id)
g["findings"] += 1
g["cves"].add(r.cve_id)
if fixed:
g["fixed"].add(fixed)
if r.kev_listed or r.euvd_listed:
g["exploited"].add(r.cve_id)
if (r.jev_config_dependent or 0) >= CONFIG_HINT_THRESHOLD:
g["config"].add(r.cve_id)
g["priority_sum"] += prio
g["priority_max"] = max(g["priority_max"], prio)
plan = [{
"product": g["product"],
"hosts": len(g["hosts"]),
"findings": g["findings"],
"cve_count": len(g["cves"]),
"fixed_versions": sorted(g["fixed"]),
"exploited_cves": sorted(g["exploited"]),
"config_dependent_cves": sorted(g["config"]),
"priority_sum": round(g["priority_sum"], 1),
"priority_max": round(g["priority_max"], 1),
} for g in groups.values()]
plan.sort(key=lambda g: (len(g["exploited_cves"]), g["priority_sum"]), reverse=True)
return plan[:limit] if limit is not None else plan
+540
View File
@@ -0,0 +1,540 @@
"""Sync run ledger + failure alerting.
Every sync job Wazuh, Nessus, Intune, vCenter, IGEL, Netdisco; button or
scheduler
runs inside `record_sync_run(source, trigger)`. That writes ONE `sync_runs`
row: started, finished, status, the stats dict, and the error that stopped it.
The ledger uses its OWN session. The job's session may be mid-transaction or
already rolled back when the job dies, and the record of the failure must not
depend on it.
A failed run sends one mail per source per ALERT_COOLDOWN (24h) an hourly
schedule against a dead API must not send 24 mails and states the last
successful sync, i.e. how long the coverage gap already is.
"""
import contextlib
import html
import json
import logging
import os
import re
import threading
from datetime import datetime, timedelta
from typing import Dict, Iterable, List, Optional
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models.sync_run import SyncRun
logger = logging.getLogger(__name__)
SOURCES = {
"wazuh": "Wazuh",
"nessus": "Tenable Nessus",
"intune": "Microsoft Intune",
"vcenter": "VMware vCenter",
"igel": "IGEL UMS",
"netdisco": "Netdisco",
"dockhand": "Dockhand",
}
# A source counts as configured when its config setting exists and is non-empty.
CONFIG_KEYS = {
"wazuh": "wazuh_config",
"nessus": "nessus_config",
"intune": "intune_config",
"vcenter": "vcenter_config",
"igel": "igel_config",
"netdisco": "netdisco_config",
"dockhand": "dockhand_config",
}
# Inventory syncs (Intune/vCenter/IGEL/Netdisco/Dockhand) are nightly: 36h means "missed one
# night" without flagging a slow one. Wazuh and Nessus follow their own
# ScanSchedule — see stale_thresholds(): twice the interval, at least 36h,
# and never stale without an enabled schedule (manual-only sources).
STALE_AFTER = timedelta(hours=36)
CRON_STALE_AFTER = timedelta(days=8) # a cron expression we do not parse
ALERT_COOLDOWN = timedelta(hours=24)
SETTING_ENABLED = "sync_alert_enabled"
SETTING_RECIPIENTS = "sync_alert_recipients"
MAX_ERRORS_KEPT = 50
_local = threading.local()
_alert_lock = threading.Lock()
class SyncRunHandle:
"""What the job sees: a stats dict to fill and fail() for a run that
ended without an exception but did nothing useful."""
def __init__(self, run_id: Optional[int], source: str):
self.run_id = run_id
self.source = source
self.stats: dict = {}
self.error: Optional[str] = None
def fail(self, message: str) -> None:
self.error = str(message)[:2000]
# An error that means the SOURCE is broken, not one agent: the login was
# refused, the API answered 5xx, nothing answered at all. The Wazuh manager
# API (55000) and the indexer (9200) are two services — the asset sync logs
# in to the first, the vulnerability sync reads the second — so the API can
# be dead while 64 agents "sync" fine. That run used to be COMPLETED · 1 ERR.
SOURCE_FAILURE_RE = re.compile(
r"authentication failed|authenticat\w* (error|refused)|invalid credentials|"
r"server error '5\d\d|client error '40[13]|\b50[0-9]\b (internal|bad gateway|service unavailable|gateway)|"
r"connection (refused|reset|failed|error)|failed to connect|all connection attempts failed|"
r"connecterror|name or service not known|nodename nor servname|network is unreachable|"
r"no route to host|ssl|certificate verify|wazuh sync failed|indexer request failed|"
r"configuration (missing|not found|incomplete)|config not set",
re.I)
def source_failure(errors: Iterable) -> Optional[str]:
"""The first error that says the source itself is down, else None."""
for e in errors or ():
if SOURCE_FAILURE_RE.search(str(e)):
return str(e)[:500]
return None
def fail_if_source_broken(handle: SyncRunHandle, triggered: int, errors: list,
phase_failures: Iterable = ()) -> None:
"""Decide whether a run that ended without an exception still failed.
phase_failures a whole phase raised (the asset sync could not log in).
Always a failure, whatever the other phases did: the inventory was
not refreshed and the API is down, and somebody has to know that.
every agent failed authentication refused, API down: failed, not
"completed with 64 errors".
a source-level error among the agents the API died mid-run, or one
service of the two is down: failed, even with 40 agents through.
anything else (an agent Wazuh no longer knows, one timeout) completed
with errors; the health panel shows it amber, no mail.
"""
phase_failures = [str(p) for p in phase_failures if p]
if phase_failures:
handle.fail("; ".join(phase_failures))
return
if triggered == 0 and errors:
handle.fail(f"all {len(errors)} agents failed — {errors[0]}")
return
hit = source_failure(errors)
if hit:
handle.fail(f"{SOURCES.get(handle.source, handle.source)} API failing mid-run "
f"({triggered} agents synced, {len(errors)} errors) — {hit}")
def fail_if_nothing_synced(handle: SyncRunHandle, triggered: int, errors: list) -> None:
"""Kept for callers that only know triggered/errors — same rules."""
fail_if_source_broken(handle, triggered, errors)
def report_source_failure(source: str, trigger: str, phase: str, error: str) -> None:
"""One failed row (and the mail, under the usual cooldown) for a source
failure discovered by a job that is not that source's sync — the nightly
app scan, SCA refresh or EOL check finding the Wazuh API refusing to log
in. Inside a running sync of the same source it marks that run instead."""
with record_sync_run(source, trigger) as run:
run.stats.setdefault("phase", phase)
run.fail(f"{phase}: {error}")
def probe_wazuh_api(client, phase: str, trigger: str = "scheduled") -> Optional[str]:
"""One login before a job walks the manager API per asset. When it is
refused, that is recorded as a failed Wazuh run otherwise the job logs
a warning per asset and finishes "completed", and the dead API is
discovered by whoever presses the button next. A login that works
writes nothing: a probe is not a sync and must not count as one.
Returns the error text, or None when the API answers."""
try:
client.login()
return None
except Exception as e:
err = _error_text(e)
logger.error("Wazuh API refused the %s login: %s", phase, err)
try:
report_source_failure("wazuh", trigger, phase, err)
except Exception as e2:
logger.error("sync_runs: could not record the %s probe failure: %s", phase, e2)
return err
def _error_text(e: BaseException) -> str:
detail = getattr(e, "detail", None) # HTTPException carries the message there
text = str(detail) if detail else (str(e) or type(e).__name__)
return text[:2000]
@contextlib.contextmanager
def record_sync_run(source: str, trigger: str = "manual"):
"""Record one sync execution. Nested use (the scheduled Wazuh scan calls
the same asset-sync function the button calls) yields the OUTER handle so
a run is one row, not two, and one mail, not two."""
outer = getattr(_local, "active", None)
if outer is not None and outer.source == source:
yield outer
return
db = SessionLocal()
run = SyncRun(source=source, trigger=trigger, status="running",
started_at=datetime.now())
try:
db.add(run)
db.commit()
except Exception as e: # ledger unavailable: the job still has to run
logger.error("sync_runs: could not open run for %s: %s", source, e)
db.close()
yield SyncRunHandle(None, source)
return
handle = SyncRunHandle(run.id, source)
_local.active = handle
try:
yield handle
except BaseException as e:
handle.error = handle.error or _error_text(e)
raise
finally:
_local.active = outer
try:
_finish(db, run, handle)
except Exception as e:
logger.error("sync_runs: could not close run %s: %s", run.id, e)
finally:
db.close()
def _finish(db: Session, run: SyncRun, handle: SyncRunHandle) -> None:
stats = dict(handle.stats)
errs = stats.get("errors")
if isinstance(errs, list):
stats["error_count"] = len(errs)
stats["errors"] = [str(x)[:500] for x in errs[:MAX_ERRORS_KEPT]]
run.finished_at = datetime.now()
run.status = "failed" if handle.error else "completed"
run.error_message = handle.error
try:
run.stats = json.dumps(stats, default=str)
except Exception:
run.stats = None
db.commit()
level = logger.error if handle.error else logger.info
level("sync run #%s %s (%s): %s%s", run.id, run.source, run.trigger, run.status,
f"{handle.error}" if handle.error else "")
if handle.error:
try:
notify_sync_failure(db, run)
except Exception as e:
logger.error("sync failure mail for %s failed: %s", run.source, e)
def mark_interrupted_runs() -> int:
"""Close rows left 'running' by a backend restart mid-sync, else the
health panel would show a spinner forever. Called from start_scheduler."""
db = SessionLocal()
try:
rows = db.query(SyncRun).filter(SyncRun.status == "running").all()
for r in rows:
r.status = "failed"
r.finished_at = datetime.now()
r.error_message = "interrupted: backend restarted while the sync was running"
if rows:
db.commit()
logger.warning("sync_runs: closed %d interrupted run(s)", len(rows))
return len(rows)
except Exception as e:
logger.error("sync_runs: interrupted-run sweep failed: %s", e)
return 0
finally:
db.close()
# ---- health ---------------------------------------------------------------
def _setting(db: Session, key: str) -> Optional[str]:
from app.models.setting import Setting
try:
row = db.query(Setting).filter(Setting.key == key).first()
except Exception:
return None
return row.value if row and row.value is not None else None
def is_alert_enabled(db: Session) -> bool:
raw = (_setting(db, SETTING_ENABLED) or "").strip().strip('"').lower()
return raw not in ("false", "0", "no", "off")
def get_recipients(db: Session) -> List[tuple]:
"""(user_id_or_None, email, display_name). Dedicated setting wins, else
the shared notification default (configured list, else all admins)."""
from app.models.user import User
from app.services.email_service import get_default_recipients
raw = _setting(db, SETTING_RECIPIENTS)
if raw and raw.strip():
out, seen = [], set()
for em in (e.strip() for e in re.split(r"[,;\s]+", raw) if e.strip()):
if em in seen:
continue
seen.add(em)
u = db.query(User).filter(User.email == em).first()
out.append(((u.id if u else None), em, (u.username if u else em)))
if out:
return out
return get_default_recipients(db)
def configured_sources(db: Session) -> set:
return {s for s, key in CONFIG_KEYS.items() if (_setting(db, key) or "").strip()}
def _serialize(run: Optional[SyncRun]) -> Optional[dict]:
if run is None:
return None
try:
stats = json.loads(run.stats) if run.stats else {}
except Exception:
stats = {}
return {
"id": run.id,
"source": run.source,
"source_label": SOURCES.get(run.source, run.source),
"trigger": run.trigger,
"status": run.status,
"started_at": run.started_at,
"finished_at": run.finished_at,
"error_message": run.error_message,
"error_count": stats.get("error_count", 0) or 0,
"stats": stats,
"alerted_at": run.alerted_at,
}
def stale_thresholds(db: Session) -> Dict[str, Optional[timedelta]]:
"""How long without a success before a source counts as stale. None =
never (no enabled schedule drives it, so silence is not a failure)."""
from app.models.scan_schedule import ScanSchedule, ScheduleInterval
interval_of = {
ScheduleInterval.EVERY_HOUR: timedelta(hours=1),
ScheduleInterval.EVERY_6_HOURS: timedelta(hours=6),
ScheduleInterval.EVERY_12_HOURS: timedelta(hours=12),
ScheduleInterval.DAILY: timedelta(days=1),
ScheduleInterval.WEEKLY: timedelta(weeks=1),
}
out: Dict[str, Optional[timedelta]] = {s: STALE_AFTER for s in ("intune", "vcenter", "igel")}
out.update({"wazuh": None, "nessus": None})
try:
schedules = db.query(ScanSchedule).filter(ScanSchedule.enabled.is_(True)).all()
except Exception:
schedules = []
for sch in schedules:
source = (getattr(sch, "scanner_type", None) or "wazuh").lower()
if source not in ("wazuh", "nessus"):
continue
every = interval_of.get(sch.interval)
limit = max(STALE_AFTER, 2 * every) if every else CRON_STALE_AFTER
cur = out.get(source)
out[source] = limit if cur is None else min(cur, limit)
return out
def summarize_health(latest: Dict[str, SyncRun], last_success: Dict[str, SyncRun],
configured: Iterable[str],
thresholds: Optional[Dict[str, Optional[timedelta]]] = None,
now: Optional[datetime] = None) -> List[dict]:
"""Pure: one entry per known source with a state the GUI can colour.
failed the most recent run of this source failed
running a run is in progress
stale configured, but no successful run within its threshold
warning last run completed, but with per-agent errors
ok last run completed and is recent enough
unconfigured no config stored for this source
Config beats history: a source whose config was removed reports
`unconfigured` even though its old runs are still in the ledger. Tying
the state to the runs meant a reset integration stayed red as `stale`
forever nothing left to run, nothing that could ever succeed again.
"""
now = now or datetime.now()
configured = set(configured)
thresholds = thresholds if thresholds is not None else {s: STALE_AFTER for s in SOURCES}
out = []
for source, label in SOURCES.items():
last = latest.get(source)
last_ser = _serialize(last)
good = last_success.get(source)
good_at = good.finished_at if good else None
if source not in configured:
state = "unconfigured"
elif last is not None and last.status == "running":
state = "running"
elif last is not None and last.status == "failed":
state = "failed"
elif (limit := thresholds.get(source)) is not None and (good_at is None or now - good_at > limit):
state = "stale"
elif last_ser and last_ser["error_count"] > 0:
state = "warning"
else:
state = "ok"
out.append({
"source": source,
"label": label,
"state": state,
"configured": source in configured,
"last_run": last_ser,
"last_success_at": good_at,
"error_message": last.error_message if last is not None else None,
})
return out
def sync_health(db: Session) -> List[dict]:
latest: Dict[str, SyncRun] = {}
last_success: Dict[str, SyncRun] = {}
for source in SOURCES:
latest[source] = (db.query(SyncRun).filter(SyncRun.source == source)
.order_by(SyncRun.started_at.desc()).first())
last_success[source] = (db.query(SyncRun)
.filter(SyncRun.source == source, SyncRun.status == "completed")
.order_by(SyncRun.finished_at.desc()).first())
return summarize_health(latest, last_success, configured_sources(db), stale_thresholds(db))
def health_payload(db: Session) -> dict:
"""What GET /scans/sync-health returns: per-source health plus the
failure-mail settings so the panel can edit them in place."""
return {
"sources": sync_health(db),
"alert_enabled": is_alert_enabled(db),
"recipients": [e for _, e, _ in get_recipients(db)],
"recipients_setting": _setting(db, SETTING_RECIPIENTS) or "",
}
def list_runs(db: Session, limit: int = 30) -> List[dict]:
rows = db.query(SyncRun).order_by(SyncRun.started_at.desc()).limit(limit).all()
return [_serialize(r) for r in rows]
# ---- failure mail ---------------------------------------------------------
def in_cooldown(last_alerted: Optional[SyncRun], now: Optional[datetime] = None) -> bool:
if last_alerted is None or last_alerted.alerted_at is None:
return False
return (now or datetime.now()) - last_alerted.alerted_at < ALERT_COOLDOWN
def _fmt(dt: Optional[datetime]) -> str:
return dt.strftime("%Y-%m-%d %H:%M") if dt else "never"
def _stats_rows(run: SyncRun) -> str:
"""Phase and counters of the failed run, so the mail says what still
went through (e.g. 64 agents over the indexer while the API was dead)."""
ser = _serialize(run) or {}
stats = ser.get("stats") or {}
rows = []
if stats.get("phase"):
rows.append(f"<tr><td><b>Phase</b></td><td>{html.escape(str(stats['phase']))}</td></tr>")
counters = " ".join(f"{k}={v}" for k, v in stats.items()
if k not in ("errors", "phase") and isinstance(v, (int, float, str)))
if counters:
rows.append(f"<tr><td><b>Run stats</b></td><td><code>{html.escape(counters)}</code></td></tr>")
errs = stats.get("errors") or []
if errs:
shown = "<br>".join(html.escape(str(e)) for e in errs[:10])
more = f"<br>… {len(errs) - 10} more" if len(errs) > 10 else ""
rows.append(f"<tr><td><b>Errors</b></td><td><code>{shown}{more}</code></td></tr>")
return "\n".join(rows)
def notify_sync_failure(db: Session, run: SyncRun) -> dict:
"""Mail the failure to the sync-alert recipients, once per source per
cooldown window, and log it in notification_logs."""
from app.models.notification_log import (NotificationLog, NotificationStatus,
NotificationType)
from app.services.email_service import send_email
stats = {"sent": 0, "failed": 0, "skipped": None}
if not is_alert_enabled(db):
stats["skipped"] = "disabled"
return stats
with _alert_lock:
return _notify_locked(db, run, stats)
def _notify_locked(db: Session, run: SyncRun, stats: dict) -> dict:
from app.models.notification_log import (NotificationLog, NotificationStatus,
NotificationType)
from app.services.email_service import send_email
last_alerted = (db.query(SyncRun)
.filter(SyncRun.source == run.source, SyncRun.alerted_at.isnot(None))
.order_by(SyncRun.alerted_at.desc()).first())
if in_cooldown(last_alerted):
stats["skipped"] = "cooldown"
logger.info("sync failure mail for %s suppressed (cooldown until %s)",
run.source, last_alerted.alerted_at + ALERT_COOLDOWN)
return stats
recipients = get_recipients(db)
if not recipients:
stats["skipped"] = "no recipients"
return stats
good = (db.query(SyncRun)
.filter(SyncRun.source == run.source, SyncRun.status == "completed")
.order_by(SyncRun.finished_at.desc()).first())
label = SOURCES.get(run.source, run.source)
base = os.getenv("DASHBOARD_URL", "http://localhost:3000").rstrip("/")
subject = f"[TRUEVULN] Sync failed: {label} ({run.trigger}) — vulnerability coverage at risk"
gap = ("No successful sync of this source has ever been recorded."
if not (good and good.finished_at) else
f"Last successful sync: {_fmt(good.finished_at)} "
f"({(datetime.now() - good.finished_at).days} day(s) ago).")
body = f"""<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;color:#111">
<h2 style="color:#b91c1c">Sync failed: {html.escape(label)}</h2>
<table cellpadding="6" style="border-collapse:collapse">
<tr><td><b>Source</b></td><td>{html.escape(label)}</td></tr>
<tr><td><b>Trigger</b></td><td>{html.escape(run.trigger)}</td></tr>
<tr><td><b>Started</b></td><td>{_fmt(run.started_at)}</td></tr>
<tr><td><b>Failed</b></td><td>{_fmt(run.finished_at)}</td></tr>
<tr><td><b>Error</b></td><td><code>{html.escape(run.error_message or "unknown")}</code></td></tr>
{_stats_rows(run)}</table>
<p>{html.escape(gap)}</p>
<p><b>Until the next successful sync, new vulnerabilities and inventory changes
from this source are not detected.</b> Check the connection and credentials in
Settings, then run the sync again from Scan Jobs.</p>
<p><a href="{html.escape(base)}/scans">Open Scan Jobs</a></p>
<p style="color:#6b7280;font-size:12px">You receive at most one mail per source
per 24 hours while it keeps failing.</p>
</body></html>"""
for user_id, email, _name in recipients:
ok, err = send_email(db, email, subject, body)
db.add(NotificationLog(
user_id=user_id,
notification_type=NotificationType.SYNC_FAILURE,
sent_at=datetime.now(),
subject=subject[:500],
recipient_email=email,
status=NotificationStatus.SENT if ok else NotificationStatus.FAILED,
message_body=f"{label} sync ({run.trigger}) failed: {run.error_message}",
error_message=None if ok else err,
))
stats["sent" if ok else "failed"] += 1
# Only a mail somebody actually got starts the cooldown — a broken SMTP
# must not silence the alert for a day.
if stats["sent"]:
run.alerted_at = datetime.now()
db.commit()
logger.info("sync failure mail for %s: %d sent, %d failed",
run.source, stats["sent"], stats["failed"])
return stats
@@ -262,6 +262,9 @@ def _parse_bulletin(slug: str, html_text: str) -> Optional[dict]:
return {"id": slug.upper(), "url": _bulletin_url(slug),
"cves": cves, "cvss": cvss, "sev": sev,
# TeamViewer's own rating. `sev` prefers the CVSS band next to the
# score, so the Priority is kept separately for the detail badge.
"vendor_sev": (meta.get("Priority") or [""])[0].strip() or None,
"desc": (desc or "")[:2000] or None,
"affected": affected}
@@ -408,6 +411,7 @@ def scan_asset(db: Session, asset, packages: list, new_ids: Optional[list] = Non
for ident in (entry["cves"] or [entry["id"]]):
c = {"cve": ident, "cvss": entry.get("cvss"),
"severity": entry.get("sev"),
"vendor_sev": ("TeamViewer", entry.get("vendor_sev")),
"fixed": row.get("lt"),
"desc": entry.get("desc"),
"refs": json.dumps([entry["url"]])}
+14 -5
View File
@@ -24,6 +24,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetSource, AssetStatus
from app.services.asset_matching import match_by_hostname, match_by_ip
logger = logging.getLogger(__name__)
@@ -113,12 +114,13 @@ def _find_or_create_asset(db: Session, *, uuid: Optional[str], hostname: str,
return a, "uuid"
for candidate in [c for c in (hostname, short) if c]:
a = db.query(Asset).filter(Asset.hostname.ilike(candidate)).first()
a = match_by_hostname(db, candidate, pin_col=Asset.vmware_uuid, pin_value=uuid, ip=ip)
if a:
_pin(a)
return a, "hostname"
if short:
a = db.query(Asset).filter(Asset.hostname.ilike(f"{short}.%")).first()
a = match_by_hostname(db, f"{short}.%", pin_col=Asset.vmware_uuid,
pin_value=uuid, ip=ip)
if a:
_pin(a)
return a, "hostname-fqdn-prefix"
@@ -126,7 +128,7 @@ def _find_or_create_asset(db: Session, *, uuid: Optional[str], hostname: str,
# Nessus scan of the same box records the IP too — matching on it keeps
# the two sources on one asset instead of creating a duplicate.
if ip:
a = db.query(Asset).filter(Asset.ip_address == ip).first()
a = match_by_ip(db, ip, hostname)
if a:
_pin(a)
return a, "ip"
@@ -138,6 +140,8 @@ def _find_or_create_asset(db: Session, *, uuid: Optional[str], hostname: str,
db.add(a)
db.flush()
logger.info("vCenter sync: auto-created asset %s", a.hostname)
from app.services.asset_lifecycle import created
created(db, a, "vCenter")
return a, "created"
return None, "skipped"
@@ -167,7 +171,7 @@ def run_vcenter_sync(db: Session, *, refresh_catalog: bool = False) -> dict:
def _run_vcenter_sync_locked(db: Session, cfg: dict, *, refresh_catalog: bool) -> dict:
from app.services.asset_lifecycle import reconcile_vcenter_by_seen_ids
from app.services.asset_lifecycle import reconcile_vcenter_by_seen_ids, rename
auto_create = bool(cfg.get("auto_create_assets", True))
sync_hosts = bool(cfg.get("sync_hosts", True))
@@ -237,7 +241,7 @@ def _run_vcenter_sync_locked(db: Session, cfg: dict, *, refresh_catalog: bool) -
# the name vCenter reports now is the current one.
name = (h.get("name") or "").strip()
if how == "uuid" and name and asset.hostname != name:
asset.hostname = name.split(".")[0] or name
rename(db, asset, name.split(".")[0] or name, "vCenter")
if h.get("ip_address"):
asset.ip_address = h["ip_address"][:45]
asset.operating_system = ESXI_OS
@@ -245,6 +249,11 @@ def _run_vcenter_sync_locked(db: Session, cfg: dict, *, refresh_catalog: bool) -
asset.os_version = str(h["version"])[:100]
if h.get("build"):
asset.vmware_build = str(h["build"])[:32]
# Display, not a match key: the host is already found by its UUID,
# and the serial is what an operator reads off the chassis or
# quotes to the vendor. Kept when a later sync reports none.
if h.get("serial"):
asset.vmware_serial = h["serial"]
model = " ".join(x for x in (h.get("vendor"), h.get("model")) if x)
asset.description = (h.get("full_name") or "") + (f"{model}" if model else "") or None
asset.last_scan = datetime.now()
+8
View File
@@ -300,6 +300,14 @@ def apply_canonical_from_siblings(db, vuln) -> bool:
if cur is None or (f == "cvss_score" and cur == 10.0 and src_val != 10.0):
setattr(vuln, f, src_val)
changed = True
# Vendor ratings are CVE-level too, but merged per vendor from EVERY
# sibling rather than copied from one: a new finding for an older CVE
# otherwise waited for the Sunday 18-month MSRC ingest (the nightly one
# reads 2 months), and a sibling without a rating must not hide another's.
for sib in siblings:
for vendor, rating in sib.vendor_severities.items():
if vendor not in vuln.vendor_severities:
changed |= vuln.set_vendor_severity(vendor, rating)
return changed
from sqlalchemy.orm import Session
+5 -33
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react';
import api from '../../../lib/api';
import { useRouter } from 'next/navigation';
import Pagination from '../../../components/shared/Pagination';
import {
ClockIcon,
UserIcon,
@@ -43,7 +44,6 @@ function affectedHost(log: AuditLog): string | null {
type SortKey = 'timestamp' | 'user_id' | 'event_type' | 'resource_type' | 'ip_address';
// Same steps as the Vulnerabilities and Assets tables. 1000 is the API cap.
const PAGE_SIZES = [50, 100, 250, 500, 1000];
// column header → sort key (null = not sortable)
const COLUMNS: { label: string; key: SortKey | null }[] = [
@@ -114,9 +114,6 @@ export default function AuditLogsPage() {
const formatDate = (dateStr: string) => new Date(dateStr).toLocaleString();
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const from = total === 0 ? 0 : page * pageSize + 1;
const to = Math.min((page + 1) * pageSize, total);
return (
<div className="space-y-6">
@@ -218,35 +215,10 @@ export default function AuditLogsPage() {
</table>
</div>
{/* Pagination bar */}
<div className="border-t border-gray-200 px-4 py-3 flex flex-wrap items-center justify-between gap-3 bg-gray-50 text-sm font-mono text-gray-600">
<div>
{total === 0 ? '0' : `${from.toLocaleString()}${to.toLocaleString()}`} of {total.toLocaleString()}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5">
<span className="text-xs text-gray-500">Per page</span>
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(0); }}
className="rounded-md border-gray-300 text-sm py-1 pl-2 pr-7 focus:border-truevuln-blue focus:ring-truevuln-blue"
>
{PAGE_SIZES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<div className="flex items-center gap-1">
<button onClick={() => setPage(0)} disabled={page === 0}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">«</button>
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50"> Prev</button>
<span className="px-2">Page {page + 1} / {totalPages.toLocaleString()}</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page + 1 >= totalPages}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">Next </button>
<button onClick={() => setPage(totalPages - 1)} disabled={page + 1 >= totalPages}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">»</button>
</div>
</div>
</div>
<Pagination page={page + 1} pageSize={pageSize} total={total}
onPageChange={(p) => setPage(p - 1)}
onPageSizeChange={(n) => { setPageSize(n); setPage(0); }}
className="bg-gray-50" />
</div>
</div>
);
+272 -164
View File
@@ -1,6 +1,10 @@
"use client";
import { PageLoader } from "@/components/ui/Loading";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import clsx from "clsx";
import { Cog6ToothIcon, KeyIcon, UserGroupIcon } from "@heroicons/react/24/outline";
import api from "../../../lib/api";
type ProviderName = "local" | "ldap" | "oidc" | "saml";
@@ -32,6 +36,15 @@ const PROVIDER_LABELS: Record<ProviderName, string> = {
const ROLE_PRESETS = ["admin", "editor", "readonly"] as const;
// Same shape as the settings page: one section at a time in a left rail,
// instead of three stacked blocks you have to scroll past to reach the
// mapping editor.
const SECTIONS = [
{ key: "overview", label: "Overview", icon: Cog6ToothIcon },
{ key: "providers", label: "Providers", icon: KeyIcon },
{ key: "mapping", label: "Role mapping", icon: UserGroupIcon },
];
export default function AuthAdminPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [mappings, setMappings] = useState<Record<string, Rule[]>>({});
@@ -40,6 +53,8 @@ export default function AuthAdminPage() {
const [testResults, setTestResults] = useState<Record<string, any>>({});
const [testing, setTesting] = useState<string>("");
const [ldapSampleUser, setLdapSampleUser] = useState("");
const [section, setSection] = useState("overview");
const router = useRouter();
const load = async () => {
const [s, m] = await Promise.all([
@@ -57,8 +72,9 @@ export default function AuthAdminPage() {
};
useEffect(() => {
load().catch(() => {
/* permission likely; render fallback */
load().catch((e) => {
// Admin-only; the sidebar hides it, the URL does not.
if (e?.response?.status === 403) router.push('/');
});
}, []);
@@ -135,179 +151,271 @@ export default function AuthAdminPage() {
const providers = status?.providers || [];
// One badge per provider instead of two. "Disabled + Incomplete" in orange
// read like an error on every provider nobody ever set up — the only real
// misconfiguration is a provider that is switched ON but not configured.
const providerState = (p: ProviderStatus) =>
p.enabled
? (p.configured
? { label: "Active", cls: "tv-tag tv-tag--ok" }
: { label: "Incomplete", cls: "tv-tag tv-tag--warn" })
: (p.configured
? { label: "Disabled", cls: "tv-tag" }
: { label: "Not set", cls: "tv-tag" });
const selectSection = (key: string) => {
setSection(key);
window.history.replaceState(null, "", `#${key}`);
};
return (
<div className="w-full max-w-5xl mx-auto p-4 space-y-6">
<header>
<h1 className="text-2xl font-bold text-gray-900 font-mono">Authentication Providers</h1>
<p className="text-sm text-gray-500 mt-1">
Read-only provider configuration is loaded from environment variables (restart required to apply).
Role mappings below are editable at runtime.
</p>
</header>
{/* Global settings */}
{status && (
<section className="bg-white border border-gray-200 rounded-md p-4 shadow-sm">
<h2 className="text-sm font-bold text-gray-700 font-mono uppercase tracking-wider mb-3">Global</h2>
<div className="grid grid-cols-2 gap-3 text-sm font-mono">
<div><span className="text-gray-500">Lookup order:</span> <span className="font-bold">{status.lookup_order.join(" → ")}</span></div>
<div><span className="text-gray-500">JIT provisioning:</span> <span className="font-bold">{status.jit_provisioning ? "ON" : "OFF"}</span></div>
<div><span className="text-gray-500">Default JIT role:</span> <span className="font-bold">{status.default_role}</span></div>
<div>
<span className="text-gray-500">Crypto key (Fernet):</span>{" "}
<span className={`font-bold ${status.crypto_key_set ? "text-green-600" : "text-red-600"}`}>
{status.crypto_key_set ? "set" : "MISSING — set AUTH_PROVIDER_CRYPTO_KEY"}
</span>
<div className="max-w-7xl">
<div className="flex flex-col lg:flex-row gap-6">
<aside className="lg:w-56 lg:shrink-0">
<div className="lg:sticky lg:top-6 bg-white border border-gray-200 shadow-sm rounded-sm p-4">
<div className="flex items-center gap-2 px-2 pb-3 mb-3 border-b border-gray-200">
<span className="h-2.5 w-2.5 bg-truevuln-blue" aria-hidden="true" />
<span className="font-mono text-sm font-bold tracking-widest text-gray-900">AUTH</span>
</div>
<nav className="flex lg:flex-col gap-1 overflow-x-auto">
{SECTIONS.map((s) => {
const isActive = s.key === section;
return (
<button
key={s.key}
type="button"
onClick={() => selectSection(s.key)}
aria-current={isActive ? "page" : undefined}
className={clsx(
"group flex items-center gap-2.5 whitespace-nowrap border-l-2 px-3 py-2 text-left font-mono text-xs uppercase tracking-widest transition-colors",
isActive
? "border-truevuln-blue bg-blue-50 font-bold text-truevuln-blue"
: "border-transparent text-gray-500 hover:bg-gray-50 hover:text-gray-900"
)}
>
<s.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
{s.label}
</button>
);
})}
</nav>
</div>
</section>
)}
</aside>
{/* Provider status cards */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
{providers.map((p) => (
<div key={p.name} className="bg-white border border-gray-200 rounded-md p-4 shadow-sm">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold font-mono">{PROVIDER_LABELS[p.name]}</h3>
<div className="flex gap-1">
<span className={`text-[10px] px-2 py-0.5 rounded font-mono font-bold uppercase ${p.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"}`}>
{p.enabled ? "Enabled" : "Disabled"}
</span>
<span className={`text-[10px] px-2 py-0.5 rounded font-mono font-bold uppercase ${p.configured ? "bg-blue-100 text-blue-700" : "bg-orange-100 text-orange-700"}`}>
{p.configured ? "Configured" : "Incomplete"}
</span>
</div>
<div className="flex-1 min-w-0 max-w-4xl">
{section === "overview" && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mb-6">
<h3 className="text-lg font-bold text-gray-900 font-mono mb-4 border-b border-gray-100 pb-2">
Authentication Providers
</h3>
<p className="text-sm text-gray-500 mb-6">
Provider configuration comes from environment variables and is read-only here
(a restart applies changes). The role mappings are editable at runtime.
</p>
{status && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-10 gap-y-1 text-sm">
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-gray-100">
<dt className="text-gray-500">Lookup order</dt>
<dd className="font-mono font-semibold text-gray-900">{status.lookup_order.join(" → ")}</dd>
</div>
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-gray-100">
<dt className="text-gray-500">JIT provisioning</dt>
<dd className="font-mono font-semibold text-gray-900">{status.jit_provisioning ? "on" : "off"}</dd>
</div>
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-gray-100">
<dt className="text-gray-500">Default JIT role</dt>
<dd className="font-mono font-semibold text-gray-900">{status.default_role}</dd>
</div>
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-gray-100">
<dt className="text-gray-500">Crypto key (Fernet)</dt>
<dd>
{status.crypto_key_set ? (
<span className="font-mono font-semibold text-gray-900">set</span>
) : (
<span className="tv-tag tv-tag--danger">missing</span>
)}
</dd>
</div>
</dl>
)}
{status && !status.crypto_key_set && (
<p className="mt-4 text-xs font-mono text-gray-500">
Provider secrets cannot be stored until <code>AUTH_PROVIDER_CRYPTO_KEY</code> is set.
</p>
)}
{!status && <PageLoader label="Loading providers…" />}
</div>
<div className="mt-2 text-xs text-gray-500 font-mono">{p.user_count} user(s) linked</div>
)}
{/* Test button (skip local) */}
{p.name !== "local" && (
<div className="mt-3 space-y-2">
{p.name === "ldap" && (
<input
type="text"
value={ldapSampleUser}
onChange={(e) => setLdapSampleUser(e.target.value)}
placeholder="Optional: sample username to search"
className="w-full text-xs font-mono border border-gray-300 rounded px-2 py-1"
/>
)}
{section === "providers" && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mb-6">
<h3 className="text-lg font-bold text-gray-900 font-mono mb-4 border-b border-gray-100 pb-2">
Providers
</h3>
{!status ? (
<PageLoader label="Loading providers…" />
) : (
<ul role="list" className="divide-y divide-gray-100">
{providers.map((p) => {
const state = providerState(p);
const res = testResults[p.name];
return (
<li key={p.name} className="py-4 first:pt-0">
<div className="flex items-center justify-between gap-x-6">
<div className="min-w-0">
<div className="flex items-center gap-x-3">
<p className="text-sm font-semibold leading-6 text-gray-900 font-mono">
{PROVIDER_LABELS[p.name]}
</p>
<span className={state.cls}>{state.label}</span>
</div>
<p className="mt-1 text-xs leading-5 text-gray-500 font-mono">
{p.user_count} user{p.user_count === 1 ? "" : "s"} linked
</p>
</div>
{p.name !== "local" && (
<div className="flex flex-none items-center gap-x-2">
{p.name === "ldap" && (
<input
type="text"
value={ldapSampleUser}
onChange={(e) => setLdapSampleUser(e.target.value)}
placeholder="Sample username (optional)"
className="w-56 text-xs font-mono border border-gray-300 rounded-md px-2 py-1.5"
/>
)}
<button
onClick={() => runTest(p.name as any)}
disabled={!p.configured || testing === p.name}
className="tv-btn"
title={p.configured ? "Run a live connection test" : "Configure the provider first"}
>
{testing === p.name ? "Testing…" : "Test connection"}
</button>
</div>
)}
</div>
{res && (
<div className="mt-3">
<span className={res.ok ? "tv-tag tv-tag--ok" : "tv-tag tv-tag--danger"}>
{res.ok ? "Connection ok" : "Connection failed"}
</span>
<pre className="mt-2 text-[11px] font-mono bg-gray-50 border border-gray-200 rounded-md p-3 max-h-60 overflow-auto whitespace-pre-wrap break-all">
{JSON.stringify(res, null, 2)}
</pre>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
)}
{section === "mapping" && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mb-6">
<div className="flex items-start justify-between gap-6 mb-4 border-b border-gray-100 pb-2">
<div>
<h3 className="text-lg font-bold text-gray-900 font-mono">Group Role mapping</h3>
<p className="text-xs text-gray-500 mt-1 max-w-2xl">
Patterns use fnmatch-style wildcards (<code>*</code>, <code>?</code>) and are matched
case-insensitively against each raw group from the IdP. First matching rule wins.
</p>
</div>
<button
onClick={() => runTest(p.name as any)}
disabled={!p.configured || testing === p.name}
className="w-full text-xs font-mono bg-truevuln-blue text-white rounded px-2 py-1 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={saveMappings}
disabled={saving}
className="tv-btn tv-btn--primary shrink-0 text-sm px-4 py-2"
>
{testing === p.name ? "Testing…" : `Test ${p.name.toUpperCase()} connection`}
</button>
{testResults[p.name] && (
<pre className="text-[10px] bg-gray-50 border border-gray-200 rounded p-2 max-h-60 overflow-auto whitespace-pre-wrap break-all">
{JSON.stringify(testResults[p.name], null, 2)}
</pre>
)}
</div>
)}
</div>
))}
</section>
{/* Role mappings editor */}
<section className="bg-white border border-gray-200 rounded-md p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-bold text-gray-700 font-mono uppercase tracking-wider">Group Role mapping</h2>
<p className="text-xs text-gray-500 mt-1">
Patterns use fnmatch-style wildcards (<code>*</code>, <code>?</code>) and are matched case-insensitively
against each raw group from the IdP. First matching rule wins.
</p>
</div>
<button
onClick={saveMappings}
disabled={saving}
className="text-sm font-mono bg-truevuln-blue text-white rounded px-4 py-2 hover:bg-blue-600 disabled:opacity-60"
>
{saving ? "Saving…" : "Save mappings"}
</button>
</div>
{saveMsg && (
<div className="mb-3 text-xs font-mono bg-gray-50 border border-gray-200 rounded p-2">
{saveMsg}
</div>
)}
{(["ldap", "oidc", "saml"] as const).map((provider) => (
<div key={provider} className="mb-6">
<h3 className="text-xs font-bold font-mono uppercase tracking-wider text-gray-600 mb-2">
{PROVIDER_LABELS[provider]}
</h3>
<div className="border border-gray-200 rounded">
<table className="min-w-full text-sm font-mono">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500 w-12">#</th>
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500">Group pattern</th>
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500 w-32"> Role</th>
<th className="px-3 py-2 w-32"></th>
</tr>
</thead>
<tbody>
{(mappings[provider] || []).map((rule, idx) => (
<tr key={idx} className="border-t border-gray-100">
<td className="px-3 py-2 text-xs text-gray-400">{idx + 1}</td>
<td className="px-3 py-2">
<input
type="text"
value={rule.pattern}
onChange={(e) => updateRule(provider, idx, { pattern: e.target.value })}
placeholder={
provider === "ldap"
? "CN=TrueVuln-Admins,*"
: provider === "oidc"
? "truevuln-admins | <azure-group-uuid>"
: "TrueVuln-Admins"
}
className="w-full text-xs border border-gray-300 rounded px-2 py-1"
/>
</td>
<td className="px-3 py-2">
<select
value={rule.role}
onChange={(e) => updateRule(provider, idx, { role: e.target.value as any })}
className="w-full text-xs border border-gray-300 rounded px-2 py-1"
>
{ROLE_PRESETS.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<button onClick={() => moveRule(provider, idx, -1)} disabled={idx === 0}
className="px-1 text-gray-400 hover:text-truevuln-blue disabled:opacity-30"></button>
<button onClick={() => moveRule(provider, idx, +1)} disabled={idx === (mappings[provider]?.length || 0) - 1}
className="px-1 text-gray-400 hover:text-truevuln-blue disabled:opacity-30"></button>
<button onClick={() => removeRule(provider, idx)}
className="ml-2 px-2 py-0.5 text-[10px] bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100">remove</button>
</td>
</tr>
))}
{(mappings[provider] || []).length === 0 && (
<tr>
<td colSpan={4} className="px-3 py-3 text-center text-xs text-gray-400 italic">
No rules users from {provider.toUpperCase()} get the default JIT role ({status?.default_role}).
</td>
</tr>
)}
</tbody>
</table>
<div className="border-t border-gray-100 p-2 bg-gray-50">
<button onClick={() => addRule(provider)}
className="text-xs font-mono px-3 py-1 bg-white border border-gray-300 rounded hover:bg-gray-100">
+ Add rule
{saving ? "Saving…" : "Save mappings"}
</button>
</div>
{saveMsg && (
<div className={`mb-4 w-full ${saveMsg.startsWith("Save failed") ? "tv-tag tv-tag--danger" : "tv-tag tv-tag--ok"} font-normal px-3 py-2 text-xs`}>
{saveMsg}
</div>
)}
{(["ldap", "oidc", "saml"] as const).map((provider) => (
<div key={provider} className="mb-6 last:mb-0">
<h4 className="text-xs font-bold font-mono uppercase tracking-wider text-gray-500 mb-2">
{PROVIDER_LABELS[provider]}
</h4>
<div className="border border-gray-200 rounded-md overflow-hidden">
<table className="min-w-full text-sm font-mono">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500 w-12">#</th>
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500">Group pattern</th>
<th className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-gray-500 w-32"> Role</th>
<th className="px-3 py-2 w-40"></th>
</tr>
</thead>
<tbody>
{(mappings[provider] || []).map((rule, idx) => (
<tr key={idx} className="border-t border-gray-100">
<td className="px-3 py-2 text-xs text-gray-400">{idx + 1}</td>
<td className="px-3 py-2">
<input
type="text"
value={rule.pattern}
onChange={(e) => updateRule(provider, idx, { pattern: e.target.value })}
placeholder={
provider === "ldap"
? "CN=TrueVuln-Admins,*"
: provider === "oidc"
? "truevuln-admins | <azure-group-uuid>"
: "TrueVuln-Admins"
}
className="w-full text-xs border border-gray-300 rounded-md px-2 py-1.5"
/>
</td>
<td className="px-3 py-2">
<select
value={rule.role}
onChange={(e) => updateRule(provider, idx, { role: e.target.value as any })}
className="w-full text-xs border border-gray-300 rounded-md px-2 py-1.5"
>
{ROLE_PRESETS.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<button onClick={() => moveRule(provider, idx, -1)} disabled={idx === 0}
className="px-1.5 text-gray-400 hover:text-truevuln-blue disabled:opacity-30"
title="Move up" aria-label="Move rule up"></button>
<button onClick={() => moveRule(provider, idx, +1)} disabled={idx === (mappings[provider]?.length || 0) - 1}
className="px-1.5 text-gray-400 hover:text-truevuln-blue disabled:opacity-30"
title="Move down" aria-label="Move rule down"></button>
<button onClick={() => removeRule(provider, idx)}
className="tv-btn ml-2 px-2 py-1">Remove</button>
</td>
</tr>
))}
{(mappings[provider] || []).length === 0 && (
<tr>
<td colSpan={4} className="px-3 py-4 text-center text-xs text-gray-500">
No rules users from {provider.toUpperCase()} get the default role
({status?.default_role}).
</td>
</tr>
)}
</tbody>
</table>
<div className="border-t border-gray-100 p-2 bg-gray-50">
<button onClick={() => addRule(provider)} className="tv-btn">
Add rule
</button>
</div>
</div>
</div>
))}
</div>
</div>
))}
</section>
)}
</div>
</div>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { PageLoader } from "@/components/ui/Loading";
import { dialog } from "@/components/ui/dialog";
// Advisory Feeds — configurable RSS sources (ZDI / CERT-EU / BSI / Cisco /
// custom). Not matched against the inventory and never alerted on: reading
// material that often publishes ahead of NVD/cvelistV5. Served from a cache
// the scheduler refreshes every 6h; opening or expanding a feed fetches nothing.
import { useEffect, useState } from 'react';
import api from '../../../lib/api';
type FeedItem = { title: string; link: string; date: string; summary: string };
type Feed = { id: string; name: string; url: string; enabled: boolean; items: FeedItem[]; error: string | null };
type FeedCfg = { id: string; name: string; url: string; enabled: boolean };
export default function AdvisoryFeedsPage() {
const [feeds, setFeeds] = useState<Feed[]>([]);
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [userRole, setUserRole] = useState('');
const [openFeed, setOpenFeed] = useState<string | null>(null);
// Admin config editor
const [cfg, setCfg] = useState<FeedCfg[]>([]);
const [showCfg, setShowCfg] = useState(false);
const [cfgMsg, setCfgMsg] = useState('');
// Items kept per feed. Held as a string so the field can be cleared while
// typing; the bounds come from the API, so there is one definition of them.
const [maxItems, setMaxItems] = useState('30');
const [limits, setLimits] = useState({ def: 30, min: 1, max: 500 });
const load = async () => {
try {
const [f, me] = await Promise.all([
api.get('/api/v1/advisories/feeds').catch(() => ({ data: { feeds: [], fetched_at: null } })),
api.get('/auth/me').catch(() => ({ data: {} })),
]);
setFeeds(f.data?.feeds || []);
setFetchedAt(f.data?.fetched_at || null);
setUserRole(me.data?.role || '');
// config mirror for the admin editor (from the cache view — same rows)
setCfg((f.data?.feeds || []).map((x: Feed) => ({ id: x.id, name: x.name, url: x.url, enabled: x.enabled })));
setLimits({
def: f.data?.max_items_default ?? 30,
min: f.data?.max_items_min ?? 1,
max: f.data?.max_items_max ?? 500,
});
setMaxItems(String(f.data?.max_items ?? f.data?.max_items_default ?? 30));
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const refresh = async () => {
setRefreshing(true);
try { await api.post('/api/v1/advisories/feeds/refresh'); await load(); }
catch (e: any) { dialog.alert(e?.response?.data?.detail || 'Refresh failed'); }
finally { setRefreshing(false); }
};
const saveCfg = async () => {
// Say no here rather than let the server silently clamp — a limit that
// quietly becomes something else is worse than a rejected one.
const n = Number(maxItems);
if (!maxItems.trim() || !Number.isFinite(n) || !Number.isInteger(n)
|| n < limits.min || n > limits.max) {
setCfgMsg(`Items per feed must be a whole number between ${limits.min} and ${limits.max}.`);
return;
}
try {
await api.put('/api/v1/settings/advisory_feeds_config', { value: JSON.stringify(cfg) });
await api.put('/api/v1/settings/advisory_feeds_max_items', { value: String(n) });
setCfgMsg('Saved — refreshing feeds…');
await api.post('/api/v1/advisories/feeds/refresh').catch(() => { });
await load();
setCfgMsg('Saved.');
} catch (e: any) {
setCfgMsg(e?.response?.data?.detail || 'Save failed');
}
};
const canEdit = userRole === 'admin' || userRole === 'editor';
if (loading) return <PageLoader label="Loading advisory feeds…" />;
return (
<div>
<div className="flex items-start justify-between gap-4 mb-6">
<p className="text-sm text-gray-500">
Vendor and CERT advisories read-only, not matched against your inventory.
<span className="block mt-0.5 font-mono text-xs text-gray-400">
{fetchedAt ? `Last fetch: ${new Date(fetchedAt).toLocaleString()}` : 'Not fetched yet'} · refreshed every 6 h
</span>
</p>
<div className="flex gap-2 flex-none">
{userRole === 'admin' && (
<button onClick={() => setShowCfg(!showCfg)} className="tv-btn">
Configure
</button>
)}
{canEdit && (
<button onClick={refresh} disabled={refreshing} className="tv-btn tv-btn--primary">
{refreshing ? 'Refreshing…' : 'Refresh feeds'}
</button>
)}
</div>
</div>
{/* Admin: feed configuration */}
{showCfg && userRole === 'admin' && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-4 mb-6">
<h3 className="text-sm font-bold font-mono text-gray-900 mb-2">Feed configuration</h3>
<p className="text-xs text-gray-500 mb-3">Enable/disable sources or add a custom RSS/Atom URL. Feeds with DOCTYPE/ENTITY declarations are refused (XXE protection).</p>
<div className="space-y-2">
{cfg.map((f, i) => (
<div key={i} className="flex items-center gap-2">
<input type="checkbox" checked={f.enabled} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, enabled: e.target.checked }; setCfg(n); }} className="h-4 w-4 rounded border-gray-300 text-truevuln-blue" />
<input type="text" value={f.name} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, name: e.target.value }; setCfg(n); }} className="w-64 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
<input type="text" value={f.url} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, url: e.target.value }; setCfg(n); }} className="flex-1 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
<button onClick={() => setCfg(cfg.filter((_, j) => j !== i))} className="text-red-600 text-xs px-2"></button>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-gray-100">
<label htmlFor="max-items" className="text-xs font-mono text-gray-700">Items per feed</label>
<input
id="max-items" type="number" inputMode="numeric"
min={limits.min} max={limits.max} step={1}
value={maxItems}
onChange={(e) => setMaxItems(e.target.value)}
className="w-24 rounded-md border-gray-300 text-xs font-mono h-8 px-2"
/>
<span className="text-xs text-gray-500">
{limits.min}{limits.max}, default {limits.def}. Applies to every feed;
takes effect on the next refresh. A feed that publishes fewer simply returns fewer.
</span>
</div>
<div className="flex items-center gap-2 mt-3">
<button onClick={() => setCfg([...cfg, { id: `custom-${Date.now()}`, name: 'Custom feed', url: '', enabled: true }])} className="text-xs font-mono px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">+ Add feed</button>
<button onClick={saveCfg} className="text-xs font-mono px-3 py-1.5 bg-truevuln-blue text-white rounded-md hover:bg-blue-600">Save</button>
{cfgMsg && <span className="text-xs font-mono text-gray-500">{cfgMsg}</span>}
</div>
</div>
)}
{feeds.filter(f => f.enabled).length === 0 && (
<p className="text-sm text-gray-400 font-mono">No feed enabled.</p>
)}
{feeds.filter(f => f.enabled).map((f) => (
<div key={f.id} className="bg-white border border-gray-200 shadow-sm rounded-sm mb-4">
<button onClick={() => setOpenFeed(openFeed === f.id ? null : f.id)} className="w-full px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between text-left">
<h3 className="text-sm font-bold font-mono text-gray-900">{f.name}
<span className="ml-2 text-xs font-normal text-gray-400">{f.items.length} items</span>
{f.error && <span className="ml-2 text-xs text-red-600">fetch failed: {f.error}</span>}
</h3>
<span className="text-gray-400 text-xs">{openFeed === f.id ? '▲' : '▼'}</span>
</button>
{(openFeed === f.id || feeds.filter(x => x.enabled).length <= 2) && (
<ul className="divide-y divide-gray-100">
{/* No display cap here: how many items a feed keeps is the
operator's "Items per feed" setting, and the header counts
f.items.length a second, hidden cap made the two disagree. */}
{f.items.map((it, i) => (
<li key={i} className="px-4 py-2 text-sm">
<a href={it.link} target="_blank" rel="noopener noreferrer" className="font-medium text-truevuln-blue hover:underline">{it.title}</a>
<span className="ml-2 text-xs text-gray-400 font-mono">{it.date}</span>
{it.summary && <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{it.summary}</p>}
</li>
))}
{f.items.length === 0 && !f.error && <li className="px-4 py-3 text-sm text-gray-400 font-mono">No items.</li>}
</ul>
)}
</div>
))}
</div>
);
}
+283
View File
@@ -0,0 +1,283 @@
"use client";
import { PageLoader } from "@/components/ui/Loading";
import { dialog } from "@/components/ui/dialog";
// Exploited (KEV) — every "actively exploited" catalog we have (CISA KEV,
// ENISA EUVD, VulnCheck KEV), merged. What is open in the environment comes
// first because that is what someone has to act on; the latest catalog
// additions are the context below it. Catalogs are cached 24h each; the stamps
// in the header are theirs, not the RSS feeds'.
import { useEffect, useState } from 'react';
import api from '../../../lib/api';
import { kevBadge } from '../../../lib/kevBadge';
type KevSource = { id: string; label: string; url: string; updated_at: string | null; configured: boolean };
export default function KevPage() {
const [kev, setKev] = useState<any[]>([]);
// KEV view controls — '' = every source the backend merged.
const [kevSource, setKevSource] = useState('');
// Source list comes from the API so a new catalog shows up here (and in
// the attribution line) without a second definition in the frontend.
const [kevSources, setKevSources] = useState<KevSource[]>([]);
const [onlyMine, setOnlyMine] = useState(false);
const [alerts, setAlerts] = useState<any>(null);
const [sending, setSending] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [alertCfg, setAlertCfg] = useState({ enabled: true, recipients: '' });
const [alertCfgMsg, setAlertCfgMsg] = useState('');
const [loading, setLoading] = useState(true);
const [userRole, setUserRole] = useState('');
const kevUrl = () => {
const p = new URLSearchParams({ limit: '15' });
if (kevSource) p.set('sources', kevSource);
if (onlyMine) p.set('in_inventory_only', 'true');
return `/api/v1/advisories/kev-recent?${p}`;
};
const load = async () => {
try {
const [k, me, al] = await Promise.all([
api.get(kevUrl()).catch(() => ({ data: { items: [] } })),
api.get('/auth/me').catch(() => ({ data: {} })),
api.get('/api/v1/advisories/kev-alerts').catch(() => ({ data: null })),
]);
setKev(k.data?.items || []);
setKevSources(k.data?.sources || []);
setAlerts(al.data || null);
if (al.data) setAlertCfg({
enabled: al.data.enabled !== false,
recipients: al.data.recipients_setting || '',
});
setUserRole(me.data?.role || '');
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
// Source / inventory filter changes refetch only the KEV list — the alert
// panel is unaffected by them.
useEffect(() => {
let stale = false;
api.get(kevUrl())
.then((r) => { if (!stale) setKev(r.data?.items || []); })
.catch(() => { });
return () => { stale = true; };
}, [kevSource, onlyMine]);
const refresh = async () => {
setRefreshing(true);
try {
const r = await api.post('/api/v1/advisories/kev/refresh');
const failed = Object.entries(r.data || {}).filter(([, v]: any) => v?.error);
if (failed.length) dialog.alert(failed.map(([s, v]: any) => `${s}: ${v.error}`).join('\n'));
await load();
} catch (e: any) {
dialog.alert(e?.response?.data?.detail || 'Refresh failed');
} finally { setRefreshing(false); }
};
const saveAlertCfg = async () => {
try {
await api.put('/api/v1/settings/kev_alert_enabled', { value: alertCfg.enabled ? 'true' : 'false' });
await api.put('/api/v1/settings/kev_alert_recipients', { value: alertCfg.recipients.trim() });
setAlertCfgMsg('Saved.');
await load();
} catch (e: any) {
setAlertCfgMsg(e?.response?.data?.detail || 'Save failed');
}
};
const sendAlerts = async () => {
if (!await dialog.confirm('Send the KEV alert mail now to the configured recipients?')) return;
setSending(true);
try {
const r = await api.post('/api/v1/advisories/kev-alerts/run');
const d = r.data || {};
dialog.alert(d.skipped
? `Nothing sent: ${d.skipped}`
: `Sent ${d.emails_sent} mail(s) covering ${d.alerts} CVE(s) on ${d.assets} system(s).`
+ (d.emails_failed ? ` ${d.emails_failed} failed — check the notification log.` : ''));
await load();
} catch (e: any) {
dialog.alert(e?.response?.data?.detail || 'Sending failed');
} finally { setSending(false); }
};
const canEdit = userRole === 'admin' || userRole === 'editor';
if (loading) return <PageLoader label="Loading KEV…" />;
return (
<div>
<div className="flex items-start justify-between gap-4 mb-6">
<p className="text-sm text-gray-500">
CVEs confirmed as exploited in the wild, matched against your inventory.
<span className="block mt-0.5 font-mono text-xs text-gray-400">
Catalogs updated:{' '}
{kevSources.map((s) => `${s.label} ${!s.configured ? 'not configured'
: s.updated_at ? new Date(s.updated_at).toLocaleString() : 'never'}`).join(' · ')}
{' '}· cached 24 h
</span>
</p>
{canEdit && (
<button onClick={refresh} disabled={refreshing} className="tv-btn tv-btn--primary flex-none">
{refreshing ? 'Refreshing…' : 'Refresh catalogs'}
</button>
)}
</div>
{/* Exploited in the wild AND open here — what the alert mail carries. */}
{alerts && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
<div className="px-4 py-3 border-b border-gray-100 bg-red-50 flex items-center justify-between gap-3 flex-wrap">
<div>
<h3 className="text-sm font-bold font-mono text-red-800">
Exploited &amp; open in your environment
</h3>
<p className="text-xs text-gray-500 mt-0.5">
Alert mail {alerts.enabled ? 'enabled' : 'disabled'} · checked hourly ·
{' '}{alerts.pending} pending · recipients: {(alerts.recipients || []).join(', ') || 'none configured'}
</p>
</div>
{canEdit && (
<button onClick={sendAlerts} disabled={sending}
className="rounded-md bg-red-700 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-red-800 disabled:opacity-50">
{sending ? 'Sending…' : 'Send alert mail now'}
</button>
)}
</div>
{/* Admin config — the alert mail points recipients here. */}
{userRole === 'admin' && (
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50/50 flex items-center gap-3 flex-wrap text-xs font-mono">
<label className="flex items-center gap-1">
<input type="checkbox" checked={alertCfg.enabled}
onChange={(e) => setAlertCfg({ ...alertCfg, enabled: e.target.checked })}
className="h-3.5 w-3.5 rounded border-gray-300 text-red-700" />
Alerting enabled
</label>
<label className="flex items-center gap-1 flex-1 min-w-[280px]">
Recipients
<input type="text" value={alertCfg.recipients}
onChange={(e) => setAlertCfg({ ...alertCfg, recipients: e.target.value })}
placeholder="empty = notification defaults (admins)"
className="flex-1 rounded-md border-gray-300 text-xs font-mono h-7 px-2" />
</label>
<button onClick={saveAlertCfg}
className="px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">Save</button>
{alertCfgMsg && <span className="text-gray-500">{alertCfgMsg}</span>}
</div>
)}
<ul className="divide-y divide-gray-100">
{(alerts.items || []).length === 0 && (
<li className="px-4 py-3 text-sm text-gray-400 font-mono">
Nothing actively exploited is open on an active asset.
</li>
)}
{(alerts.items || []).map((a: any) => (
<li key={a.cve_id} className="px-4 py-2 text-sm">
<div className="flex items-center justify-between gap-3">
<a href={`/vulnerabilities?cve_id=${a.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{a.cve_id}</a>
<span className="flex-none text-xs font-mono">
<span className="rounded px-1.5 py-0.5 bg-red-100 text-red-700 font-bold">{a.open_asset_count} OPEN</span>
{a.patched_asset_count > 0 && <span className="ml-1 text-gray-400">{a.patched_asset_count} remediated</span>}
{a.previously_notified && <span className="ml-2 text-gray-400">already alerted</span>}
</span>
</div>
<p className="text-xs text-gray-500 font-mono mt-0.5 truncate">
{(a.hosts || []).map((h: any) => h.hostname).join(', ')}
{a.hosts_truncated > 0 && ` … +${a.hosts_truncated} more`}
</p>
</li>
))}
</ul>
</div>
)}
{/* Latest catalog additions every KEV source merged. A CVE listed
by several carries several badges instead of appearing twice. */}
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between gap-3 flex-wrap">
<h3 className="text-sm font-bold font-mono text-gray-900">
Latest KEV additions
{/* Source attribution. VulnCheck's community licence
requires it to be named prominently wherever its
data is shown. */}
<span className="ml-2 font-normal text-gray-500">
{(kevSources.length ? kevSources.map((s) => s.label)
: ['CISA KEV', 'ENISA EUVD']).join(' · ')} · RANSOM = known ransomware use
</span>
</h3>
<div className="flex items-center gap-3 text-xs font-mono">
<label className="flex items-center gap-1 text-gray-700">
<input type="checkbox" checked={onlyMine} onChange={(e) => setOnlyMine(e.target.checked)}
className="h-3.5 w-3.5 rounded border-gray-300 text-red-700" />
Only in my inventory
</label>
<select value={kevSource} onChange={(e) => setKevSource(e.target.value)}
className="rounded-md border-gray-300 text-xs font-mono h-7 py-0 pl-2 pr-7">
<option value="">All sources</option>
{kevSources.map((s) => (
<option key={s.id} value={s.id}>{s.label} only</option>
))}
</select>
</div>
</div>
<ul className="divide-y divide-gray-100">
{kev.length === 0 && <li className="px-4 py-3 text-sm text-gray-400 font-mono">
{onlyMine ? 'Nothing actively exploited is open in your inventory.' : 'No KEV data yet — refresh the catalogs.'}
</li>}
{kev.map((k: any, i: number) => (
<li key={i} className="px-4 py-2 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<a href={`/vulnerabilities?cve_id=${k.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{k.cve_id}</a>
{(k.sources || []).map((s: string) => (
<span key={s}
title={s === 'euvd' && k.euvd_id
? `ENISA EUVD — exploited (${k.euvd_id})`
: kevBadge(s).title}
className={`ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono uppercase ${kevBadge(s).cls}`}>
{kevBadge(s).short}
</span>
))}
{/* Lead time is the reason VulnCheck is here at
all: how many days it beat CISA by. */}
{k.lead_days > 0 && (
<span title={`VulnCheck KEV listed this ${k.lead_days} day(s) before CISA`}
className="ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono bg-amber-50 text-amber-700">
{k.lead_days}d
</span>
)}
{k.ransomware && (
<span title="Known Ransomware campaign use"
className="ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono bg-red-50 text-red-700">
RANSOM
</span>
)}
{/* CISA rows carry a short vuln NAME; EUVD-only
rows have vendor/product plus a paragraph of
description so the short label first, and
the description only as a clipped last resort. */}
<span className="ml-2 text-gray-600" title={k.description || undefined}>
{k.name || [k.vendor, k.product].filter(Boolean).join(' · ')
|| (k.description || '').slice(0, 140)}
</span>
</div>
<div className="flex items-center gap-2 flex-none text-xs font-mono">
{k.in_inventory && (
<span className={`rounded px-1.5 py-0.5 font-bold ${k.open_asset_count > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}
title={`${k.open_asset_count} asset(s) still open, ${k.patched_asset_count} already remediated`}>
{k.open_asset_count > 0 ? `IN INVENTORY · ${k.open_asset_count}` : 'REMEDIATED'}
</span>
)}
<span className="text-gray-400">{k.date_added || ''}</span>
</div>
</li>
))}
</ul>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
// Threat Intel — what happens outside the environment, before any scanner
// reports it. Two tabs on purpose, because they are different kinds of thing:
// Exploited (KEV) — structured CVE list, matched against the inventory,
// drives the alert mail. Something to act on.
// Advisory Feeds — vendor / CERT RSS, not matched, no alert. Reading.
// Each tab states its own freshness and has its own refresh; one stamp in a
// shared header is what made the RSS time look like the KEV time.
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const TABS = [
{ href: '/advisories/kev', label: 'Exploited (KEV)' },
{ href: '/advisories/feeds', label: 'Advisory Feeds' },
];
export default function ThreatIntelLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<div className="p-8">
<h2 className="text-3xl font-bold text-gray-900 font-mono">Threat Intel</h2>
<p className="mt-1 text-sm text-gray-500">
What is exploited or disclosed out there, often before NVD / cvelistV5 or any scanner reports it.
</p>
<nav className="mt-4 mb-6 flex gap-6 border-b border-gray-200">
{TABS.map((t) => (
<Link key={t.href} href={t.href}
className={`-mb-px border-b-2 pb-2 text-sm font-mono font-semibold ${pathname === t.href
? 'border-truevuln-blue text-truevuln-blue'
: 'border-transparent text-gray-500 hover:text-gray-800'}`}>
{t.label}
</Link>
))}
</nav>
{children}
</div>
);
}
+5 -382
View File
@@ -1,384 +1,7 @@
"use client";
import { redirect } from 'next/navigation';
// Security Advisory Feeds — CISA KEV (actively exploited) plus configurable
// RSS sources (ZDI / CERT-EU / BSI / Cisco / custom). These sources publish
// ahead of NVD/cvelistV5, so this page is the early-warning surface.
import { useEffect, useState } from 'react';
import api from '../../lib/api';
import { kevBadge } from '../../lib/kevBadge';
type FeedItem = { title: string; link: string; date: string; summary: string };
type Feed = { id: string; name: string; url: string; enabled: boolean; items: FeedItem[]; error: string | null };
type FeedCfg = { id: string; name: string; url: string; enabled: boolean };
type KevSource = { id: string; label: string; url: string };
export default function AdvisoriesPage() {
const [kev, setKev] = useState<any[]>([]);
// KEV view controls — '' = every source the backend merged.
const [kevSource, setKevSource] = useState('');
// Source list comes from the API so a new catalog shows up here (and in
// the attribution line) without a second definition in the frontend.
const [kevSources, setKevSources] = useState<KevSource[]>([]);
const [onlyMine, setOnlyMine] = useState(false);
const [alerts, setAlerts] = useState<any>(null);
const [sending, setSending] = useState(false);
const [alertCfg, setAlertCfg] = useState({ enabled: true, recipients: '' });
const [alertCfgMsg, setAlertCfgMsg] = useState('');
const [feeds, setFeeds] = useState<Feed[]>([]);
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [userRole, setUserRole] = useState('');
const [openFeed, setOpenFeed] = useState<string | null>(null);
// Admin config editor
const [cfg, setCfg] = useState<FeedCfg[]>([]);
const [showCfg, setShowCfg] = useState(false);
const [cfgMsg, setCfgMsg] = useState('');
// Items kept per feed. Held as a string so the field can be cleared while
// typing; the bounds come from the API, so there is one definition of them.
const [maxItems, setMaxItems] = useState('30');
const [limits, setLimits] = useState({ def: 30, min: 1, max: 500 });
const kevUrl = () => {
const p = new URLSearchParams({ limit: '15' });
if (kevSource) p.set('sources', kevSource);
if (onlyMine) p.set('in_inventory_only', 'true');
return `/api/v1/advisories/kev-recent?${p}`;
};
const load = async () => {
try {
const [k, f, me, al] = await Promise.all([
api.get(kevUrl()).catch(() => ({ data: { items: [] } })),
api.get('/api/v1/advisories/feeds').catch(() => ({ data: { feeds: [], fetched_at: null } })),
api.get('/auth/me').catch(() => ({ data: {} })),
api.get('/api/v1/advisories/kev-alerts').catch(() => ({ data: null })),
]);
setKev(k.data?.items || []);
setKevSources(k.data?.sources || []);
setAlerts(al.data || null);
if (al.data) setAlertCfg({
enabled: al.data.enabled !== false,
recipients: al.data.recipients_setting || '',
});
setFeeds(f.data?.feeds || []);
setFetchedAt(f.data?.fetched_at || null);
setUserRole(me.data?.role || '');
// config mirror for the admin editor (from the cache view — same rows)
setCfg((f.data?.feeds || []).map((x: Feed) => ({ id: x.id, name: x.name, url: x.url, enabled: x.enabled })));
setLimits({
def: f.data?.max_items_default ?? 30,
min: f.data?.max_items_min ?? 1,
max: f.data?.max_items_max ?? 500,
});
setMaxItems(String(f.data?.max_items ?? f.data?.max_items_default ?? 30));
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
// Source / inventory filter changes refetch only the KEV list — the RSS
// feeds and the alert panel are unaffected by them.
useEffect(() => {
let stale = false;
api.get(kevUrl())
.then((r) => { if (!stale) setKev(r.data?.items || []); })
.catch(() => { });
return () => { stale = true; };
}, [kevSource, onlyMine]);
const saveAlertCfg = async () => {
try {
await api.put('/api/v1/settings/kev_alert_enabled', { value: alertCfg.enabled ? 'true' : 'false' });
await api.put('/api/v1/settings/kev_alert_recipients', { value: alertCfg.recipients.trim() });
setAlertCfgMsg('Saved.');
await load();
} catch (e: any) {
setAlertCfgMsg(e?.response?.data?.detail || 'Save failed');
}
};
const sendAlerts = async () => {
if (!confirm('Send the KEV alert mail now to the configured recipients?')) return;
setSending(true);
try {
const r = await api.post('/api/v1/advisories/kev-alerts/run');
const d = r.data || {};
alert(d.skipped
? `Nothing sent: ${d.skipped}`
: `Sent ${d.emails_sent} mail(s) covering ${d.alerts} CVE(s) on ${d.assets} system(s).`
+ (d.emails_failed ? ` ${d.emails_failed} failed — check the notification log.` : ''));
await load();
} catch (e: any) {
alert(e?.response?.data?.detail || 'Sending failed');
} finally { setSending(false); }
};
const refresh = async () => {
setRefreshing(true);
try { await api.post('/api/v1/advisories/feeds/refresh'); await load(); }
catch (e: any) { alert(e?.response?.data?.detail || 'Refresh failed'); }
finally { setRefreshing(false); }
};
const saveCfg = async () => {
// Say no here rather than let the server silently clamp — a limit that
// quietly becomes something else is worse than a rejected one.
const n = Number(maxItems);
if (!maxItems.trim() || !Number.isFinite(n) || !Number.isInteger(n)
|| n < limits.min || n > limits.max) {
setCfgMsg(`Items per feed must be a whole number between ${limits.min} and ${limits.max}.`);
return;
}
try {
await api.put('/api/v1/settings/advisory_feeds_config', { value: JSON.stringify(cfg) });
await api.put('/api/v1/settings/advisory_feeds_max_items', { value: String(n) });
setCfgMsg('Saved — refreshing feeds…');
await api.post('/api/v1/advisories/feeds/refresh').catch(() => { });
await load();
setCfgMsg('Saved.');
} catch (e: any) {
setCfgMsg(e?.response?.data?.detail || 'Save failed');
}
};
const canEdit = userRole === 'admin' || userRole === 'editor';
if (loading) return <div className="p-8">Loading Advisories...</div>;
return (
<div className="p-8">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-mono">Security Advisory Feeds</h2>
<p className="mt-1 text-sm text-gray-500">
Early-warning sources that often publish before NVD / cvelistV5.
{fetchedAt && <span className="ml-2 font-mono text-xs text-gray-400">Last fetch: {new Date(fetchedAt).toLocaleString()}</span>}
</p>
</div>
<div className="flex gap-2">
{userRole === 'admin' && (
<button onClick={() => setShowCfg(!showCfg)} className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
Configure
</button>
)}
{canEdit && (
<button onClick={refresh} disabled={refreshing} className="rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50">
{refreshing ? 'Refreshing…' : 'Refresh now'}
</button>
)}
</div>
</div>
{/* Admin: feed configuration */}
{showCfg && userRole === 'admin' && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-4 mb-6">
<h3 className="text-sm font-bold font-mono text-gray-900 mb-2">Feed configuration</h3>
<p className="text-xs text-gray-500 mb-3">Enable/disable sources or add a custom RSS/Atom URL. Feeds with DOCTYPE/ENTITY declarations are refused (XXE protection).</p>
<div className="space-y-2">
{cfg.map((f, i) => (
<div key={i} className="flex items-center gap-2">
<input type="checkbox" checked={f.enabled} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, enabled: e.target.checked }; setCfg(n); }} className="h-4 w-4 rounded border-gray-300 text-truevuln-blue" />
<input type="text" value={f.name} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, name: e.target.value }; setCfg(n); }} className="w-64 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
<input type="text" value={f.url} onChange={(e) => { const n = [...cfg]; n[i] = { ...f, url: e.target.value }; setCfg(n); }} className="flex-1 rounded-md border-gray-300 text-xs font-mono h-8 px-2" />
<button onClick={() => setCfg(cfg.filter((_, j) => j !== i))} className="text-red-600 text-xs px-2"></button>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-gray-100">
<label htmlFor="max-items" className="text-xs font-mono text-gray-700">Items per feed</label>
<input
id="max-items" type="number" inputMode="numeric"
min={limits.min} max={limits.max} step={1}
value={maxItems}
onChange={(e) => setMaxItems(e.target.value)}
className="w-24 rounded-md border-gray-300 text-xs font-mono h-8 px-2"
/>
<span className="text-xs text-gray-500">
{limits.min}{limits.max}, default {limits.def}. Applies to every feed;
takes effect on the next refresh. A feed that publishes fewer simply returns fewer.
</span>
</div>
<div className="flex items-center gap-2 mt-3">
<button onClick={() => setCfg([...cfg, { id: `custom-${Date.now()}`, name: 'Custom feed', url: '', enabled: true }])} className="text-xs font-mono px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">+ Add feed</button>
<button onClick={saveCfg} className="text-xs font-mono px-3 py-1.5 bg-truevuln-blue text-white rounded-md hover:bg-blue-600">Save</button>
{cfgMsg && <span className="text-xs font-mono text-gray-500">{cfgMsg}</span>}
</div>
</div>
)}
{/* Actively exploited every KEV source we have, merged. A CVE
listed by both carries both badges instead of appearing twice. */}
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
<div className="px-4 py-3 border-b border-gray-100 bg-red-50 flex items-center justify-between gap-3 flex-wrap">
<h3 className="text-sm font-bold font-mono text-red-800">
Actively Exploited KEV (latest additions)
{/* Source attribution. VulnCheck's community licence
requires it to be named prominently wherever its
data is shown. */}
<span className="ml-2 font-normal text-red-700/70">
{(kevSources.length ? kevSources.map((s) => s.label)
: ['CISA KEV', 'ENISA EUVD']).join(' · ')} · 🔒 = Ransomware use
</span>
</h3>
<div className="flex items-center gap-3 text-xs font-mono">
<label className="flex items-center gap-1 text-red-800">
<input type="checkbox" checked={onlyMine} onChange={(e) => setOnlyMine(e.target.checked)}
className="h-3.5 w-3.5 rounded border-red-300 text-red-700" />
Only in my inventory
</label>
<select value={kevSource} onChange={(e) => setKevSource(e.target.value)}
className="rounded-md border-gray-300 text-xs font-mono h-7 py-0 pl-2 pr-7">
<option value="">All sources</option>
{kevSources.map((s) => (
<option key={s.id} value={s.id}>{s.label} only</option>
))}
</select>
</div>
</div>
<ul className="divide-y divide-gray-100">
{kev.length === 0 && <li className="px-4 py-3 text-sm text-gray-400 font-mono">
{onlyMine ? 'Nothing actively exploited is open in your inventory.' : 'No KEV data yet — run Refresh Threat Intel.'}
</li>}
{kev.map((k: any, i: number) => (
<li key={i} className="px-4 py-2 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<a href={`/vulnerabilities?cve_id=${k.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{k.cve_id}</a>
{(k.sources || []).map((s: string) => (
<span key={s}
title={s === 'euvd' && k.euvd_id
? `ENISA EUVD — exploited (${k.euvd_id})`
: kevBadge(s).title}
className={`ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono uppercase ${kevBadge(s).cls}`}>
{kevBadge(s).short}
</span>
))}
{/* Lead time is the reason VulnCheck is here at
all: how many days it beat CISA by. */}
{k.lead_days > 0 && (
<span title={`VulnCheck KEV listed this ${k.lead_days} day(s) before CISA`}
className="ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono bg-amber-50 text-amber-700">
{k.lead_days}d
</span>
)}
{k.ransomware && <span title="Known Ransomware campaign use" className="ml-1">🔒</span>}
{/* CISA rows carry a short vuln NAME; EUVD-only
rows have vendor/product plus a paragraph of
description so the short label first, and
the description only as a clipped last resort. */}
<span className="ml-2 text-gray-600" title={k.description || undefined}>
{k.name || [k.vendor, k.product].filter(Boolean).join(' · ')
|| (k.description || '').slice(0, 140)}
</span>
</div>
<div className="flex items-center gap-2 flex-none text-xs font-mono">
{k.in_inventory && (
<span className={`rounded px-1.5 py-0.5 font-bold ${k.open_asset_count > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}
title={`${k.open_asset_count} asset(s) still open, ${k.patched_asset_count} already remediated`}>
{k.open_asset_count > 0 ? `IN INVENTORY · ${k.open_asset_count}` : 'REMEDIATED'}
</span>
)}
<span className="text-gray-400">{k.date_added || ''}</span>
</div>
</li>
))}
</ul>
</div>
{/* Immediate alerting: exploited in the wild AND open here. */}
{alerts && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm mb-6">
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between gap-3 flex-wrap">
<div>
<h3 className="text-sm font-bold font-mono text-gray-900">
KEV Alerting actively exploited &amp; open in your environment
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{alerts.enabled ? 'Enabled' : 'Disabled'} · checked hourly ·
{' '}{alerts.pending} pending · recipients: {(alerts.recipients || []).join(', ') || 'none configured'}
</p>
</div>
{canEdit && (
<button onClick={sendAlerts} disabled={sending}
className="rounded-md bg-red-700 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-red-800 disabled:opacity-50">
{sending ? 'Sending…' : 'Send alert mail now'}
</button>
)}
</div>
{/* Admin config — the alert mail points recipients here. */}
{userRole === 'admin' && (
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50/50 flex items-center gap-3 flex-wrap text-xs font-mono">
<label className="flex items-center gap-1">
<input type="checkbox" checked={alertCfg.enabled}
onChange={(e) => setAlertCfg({ ...alertCfg, enabled: e.target.checked })}
className="h-3.5 w-3.5 rounded border-gray-300 text-red-700" />
Alerting enabled
</label>
<label className="flex items-center gap-1 flex-1 min-w-[280px]">
Recipients
<input type="text" value={alertCfg.recipients}
onChange={(e) => setAlertCfg({ ...alertCfg, recipients: e.target.value })}
placeholder="empty = notification defaults (admins)"
className="flex-1 rounded-md border-gray-300 text-xs font-mono h-7 px-2" />
</label>
<button onClick={saveAlertCfg}
className="px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50">Save</button>
{alertCfgMsg && <span className="text-gray-500">{alertCfgMsg}</span>}
</div>
)}
<ul className="divide-y divide-gray-100">
{(alerts.items || []).length === 0 && (
<li className="px-4 py-3 text-sm text-gray-400 font-mono">
Nothing actively exploited is open on an active asset. 🎉
</li>
)}
{(alerts.items || []).map((a: any) => (
<li key={a.cve_id} className="px-4 py-2 text-sm">
<div className="flex items-center justify-between gap-3">
<a href={`/vulnerabilities?cve_id=${a.cve_id}`} className="font-mono font-semibold text-truevuln-blue hover:underline">{a.cve_id}</a>
<span className="flex-none text-xs font-mono">
<span className="rounded px-1.5 py-0.5 bg-red-100 text-red-700 font-bold">{a.open_asset_count} OPEN</span>
{a.patched_asset_count > 0 && <span className="ml-1 text-gray-400">{a.patched_asset_count} remediated</span>}
{a.previously_notified && <span className="ml-2 text-gray-400">already alerted</span>}
</span>
</div>
<p className="text-xs text-gray-500 font-mono mt-0.5 truncate">
{(a.hosts || []).map((h: any) => h.hostname).join(', ')}
{a.hosts_truncated > 0 && ` … +${a.hosts_truncated} more`}
</p>
</li>
))}
</ul>
</div>
)}
{/* RSS feeds */}
{feeds.filter(f => f.enabled).map((f) => (
<div key={f.id} className="bg-white border border-gray-200 shadow-sm rounded-sm mb-4">
<button onClick={() => setOpenFeed(openFeed === f.id ? null : f.id)} className="w-full px-4 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between text-left">
<h3 className="text-sm font-bold font-mono text-gray-900">{f.name}
<span className="ml-2 text-xs font-normal text-gray-400">{f.items.length} items</span>
{f.error && <span className="ml-2 text-xs text-red-600">fetch failed: {f.error}</span>}
</h3>
<span className="text-gray-400 text-xs">{openFeed === f.id ? '▲' : '▼'}</span>
</button>
{(openFeed === f.id || feeds.filter(x => x.enabled).length <= 2) && (
<ul className="divide-y divide-gray-100">
{/* No display cap here: how many items a feed keeps is the
operator's "Items per feed" setting, and the header counts
f.items.length a second, hidden cap made the two disagree. */}
{f.items.map((it, i) => (
<li key={i} className="px-4 py-2 text-sm">
<a href={it.link} target="_blank" rel="noopener noreferrer" className="font-medium text-truevuln-blue hover:underline">{it.title}</a>
<span className="ml-2 text-xs text-gray-400 font-mono">{it.date}</span>
{it.summary && <p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{it.summary}</p>}
</li>
))}
{f.items.length === 0 && !f.error && <li className="px-4 py-3 text-sm text-gray-400 font-mono">No items.</li>}
</ul>
)}
</div>
))}
</div>
);
// Threat Intel lives in two tabs (see layout.tsx); the bare URL — the old
// Advisories page, still bookmarked and linked — opens the KEV tab.
export default function ThreatIntelIndex() {
redirect('/advisories/kev');
}
+124 -57
View File
@@ -1,11 +1,15 @@
"use client";
import { PageLoader } from "@/components/ui/Loading";
import { dialog } from "@/components/ui/dialog";
import { useEffect, useState } from 'react';
import api from '../../lib/api';
import { formatScanStats } from '../../lib/scanStats';
import { Asset, UserInfo, Group } from '../../types';
import Link from 'next/link';
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon, MagnifyingGlassIcon, ListBulletIcon, ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import Pagination from '../../components/shared/Pagination';
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon, MagnifyingGlassIcon, ListBulletIcon } from '@heroicons/react/24/outline';
import { UserCircleIcon } from '@heroicons/react/24/solid';
export default function AssetsPage() {
@@ -21,6 +25,9 @@ export default function AssetsPage() {
const [searchText, setSearchText] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [sourceFilter, setSourceFilter] = useState('');
// Container images (one asset each) are hidden by default: the list is for
// hosts, a Docker host shows its images via its "N images" badge.
const [showContainers, setShowContainers] = useState(false);
// Table sort state
const [sortBy, setSortBy] = useState<string>('hostname');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
@@ -30,6 +37,8 @@ export default function AssetsPage() {
// Installed-software (on-demand) modal
const [softwareReport, setSoftwareReport] = useState<any | null>(null);
const [softwareLoading, setSoftwareLoading] = useState<number | null>(null);
// Container images on a Docker host (Dockhand) modal
const [containersReport, setContainersReport] = useState<any | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [exportLoading, setExportLoading] = useState(false);
// Pagination
@@ -71,6 +80,7 @@ export default function AssetsPage() {
if (searchText) params.search = searchText;
if (showInactive) params.include_inactive = true;
if (sourceFilter) params.source = sourceFilter;
if (!showContainers) params.include_containers = false;
params.sort_by = sortBy;
params.sort_order = sortOrder;
params.limit = pageSize;
@@ -131,16 +141,15 @@ export default function AssetsPage() {
useEffect(() => {
setPage(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchText, showInactive, sortBy, sortOrder, pageSize, sourceFilter]);
}, [searchText, showInactive, sortBy, sortOrder, pageSize, sourceFilter, showContainers]);
// Fetch on page / filter / search change (debounced for typing).
useEffect(() => {
const t = setTimeout(fetchAssets, searchText ? 300 : 0);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, sortBy, sortOrder, showInactive, searchText, sourceFilter]);
}, [page, pageSize, sortBy, sortOrder, showInactive, searchText, sourceFilter, showContainers]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const resetForm = () => {
setFormData({
@@ -173,7 +182,7 @@ export default function AssetsPage() {
} catch (error: any) {
console.error("Failed to save asset:", error);
const msg = error.response?.data?.detail || "Failed to save asset.";
alert(`Error: ${msg}`);
dialog.alert(`Error: ${msg}`);
}
};
@@ -218,6 +227,14 @@ export default function AssetsPage() {
['Intune device ID', asset.intune_device_id],
['Defender machine ID', asset.defender_machine_id],
['Wazuh agent ID', asset.wazuh_agent_id],
['Intune serial', asset.intune_serial],
['Nessus host UUID', asset.nessus_host_uuid],
['VMware UUID', asset.vmware_uuid],
['ESXi host serial', asset.vmware_serial],
['IGEL unit ID (MAC)', asset.igel_unit_id],
['IGEL serial', asset.igel_serial],
['Netdisco device IP', asset.netdisco_device_ip],
['Netdisco serial', asset.netdisco_serial],
] as [string, string | null | undefined][]).filter(([, v]) => !!v) as [string, string][]);
setIsModalOpen(true);
};
@@ -228,7 +245,7 @@ export default function AssetsPage() {
const res = await api.get(`/api/v1/assets/${asset.id}/coverage-gap`);
setGapReport(res.data);
} catch (e: any) {
alert(`Coverage-gap failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
dialog.alert(`Coverage-gap failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
} finally {
setGapLoading(null);
}
@@ -238,11 +255,11 @@ export default function AssetsPage() {
setRescanLoading(id);
try {
const res = await api.post(`/api/v1/assets/${id}/rescan`);
alert(res.data.message);
dialog.alert(res.data.message);
fetchAssets();
} catch (error: any) {
console.error("Rescan failed:", error);
alert(error.response?.data?.detail || "Rescan failed.");
dialog.alert(error.response?.data?.detail || "Rescan failed.");
} finally {
setRescanLoading(null);
}
@@ -254,12 +271,21 @@ export default function AssetsPage() {
const res = await api.get(`/api/v1/assets/${asset.id}/software`);
setSoftwareReport(res.data);
} catch (error: any) {
alert(error.response?.data?.detail || 'Could not load installed software.');
dialog.alert(error.response?.data?.detail || 'Could not load installed software.');
} finally {
setSoftwareLoading(null);
}
};
const handleShowContainers = async (hostId: number) => {
try {
const res = await api.get(`/api/v1/assets/${hostId}/containers`);
setContainersReport(res.data);
} catch (error: any) {
dialog.alert(error.response?.data?.detail || 'Could not load container images.');
}
};
const handleAppRescan = async (asset: Asset) => {
setAppScanLoading(asset.id);
try {
@@ -267,12 +293,12 @@ export default function AssetsPage() {
// Same counters the global scan button reports — this used to name
// three of them and drop the rest (FP-suppressed, pruned packages).
const extra = formatScanStats(res.data, ['assets', 'findings', 'new', 'errors']);
alert(`App CVE re-scan done for ${asset.hostname}: `
dialog.alert(`App CVE re-scan done for ${asset.hostname}: `
+ `${res.data?.findings ?? 0} findings (${res.data?.new ?? 0} new)`
+ (extra ? ` · ${extra}` : '') + '.');
fetchAssets();
} catch (error: any) {
alert(error.response?.data?.detail || 'App re-scan failed.');
dialog.alert(error.response?.data?.detail || 'App re-scan failed.');
} finally {
setAppScanLoading(null);
}
@@ -280,10 +306,10 @@ export default function AssetsPage() {
const handleNessusRescan = async (asset: Asset) => {
if (!asset.ip_address) {
alert('Asset has no IP address — Nessus cannot target it.');
dialog.alert('Asset has no IP address — Nessus cannot target it.');
return;
}
if (!confirm(
if (!await dialog.confirm(
`Launch a targeted Nessus scan for ${asset.hostname} (${asset.ip_address})?\n\n` +
`Uses the scan configured in Settings → Tenable Nessus (default_scan_ids), ` +
`but targets only this host's IP. After the scan completes in Nessus, ` +
@@ -295,23 +321,23 @@ export default function AssetsPage() {
const res = await api.post('/api/v1/vulnerabilities/nessus/scan-host', {
asset_id: asset.id,
});
alert(res.data.message || 'Nessus scan launched successfully.');
dialog.alert(res.data.message || 'Nessus scan launched successfully.');
} catch (error: any) {
const detail = error.response?.data?.detail || 'Failed to launch Nessus scan.';
alert(`Nessus rescan failed: ${detail}`);
dialog.alert(`Nessus rescan failed: ${detail}`);
} finally {
setNessusRescanLoading(null);
}
};
const handleDeleteAsset = async (id: number) => {
if (!confirm("Are you sure you want to delete this asset?")) return;
if (!await dialog.confirm("Are you sure you want to delete this asset?", { tone: "danger", confirmLabel: "Delete asset" })) return;
try {
await api.delete(`/api/v1/assets/${id}`);
fetchAssets();
} catch (error) {
console.error("Failed to delete asset:", error);
alert("Failed to delete asset.");
dialog.alert("Failed to delete asset.");
}
};
@@ -342,7 +368,7 @@ export default function AssetsPage() {
fetchAssets();
} catch (error) {
console.error("Bulk update failed", error);
alert("Bulk update failed.");
dialog.alert("Bulk update failed.");
}
};
@@ -369,6 +395,7 @@ export default function AssetsPage() {
if (searchText) params.search = searchText;
if (sourceFilter) params.source = sourceFilter;
if (showInactive) params.include_inactive = true;
if (!showContainers) params.include_containers = false;
const response = await api.get('/api/v1/assets/export', { params, responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
@@ -385,13 +412,13 @@ export default function AssetsPage() {
window.URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
alert("Failed to export assets. Please ensure you are logged in.");
dialog.alert("Failed to export assets. Please ensure you are logged in.");
} finally {
setExportLoading(false);
}
};
if (loading) return <div className="p-8">Loading Assets...</div>;
if (loading) return <PageLoader label="Loading assets…" />;
// Mutating actions require editor+ (rescans/edit) or admin (delete) server-
// side; hide them from read-only users so they don't get a 403 alert.
@@ -437,6 +464,8 @@ export default function AssetsPage() {
<option value="INTUNE">Intune / Defender</option>
<option value="VCENTER">vCenter / ESXi</option>
<option value="IGEL">IGEL UMS</option>
<option value="NETDISCO">Netdisco</option>
<option value="CONTAINER">Container images</option>
<option value="MANUAL">Manual</option>
</select>
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="INACTIVE assets are always shown (amber badge). Tick to also show operator-retired DECOMMISSIONED assets.">
@@ -448,33 +477,41 @@ export default function AssetsPage() {
/>
Show decommissioned
</label>
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="Container images are one asset each. Hidden by default; a Docker host lists its images via its images badge.">
<input
type="checkbox"
checked={showContainers}
onChange={(e) => setShowContainers(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-truevuln-blue focus:ring-truevuln-blue"
/>
Show container images
</label>
{canEdit && (
<button
type="button"
onClick={async () => {
if (!confirm('Refresh network-exposure scores from Wazuh syscollector ports for all assets?')) return;
if (!await dialog.confirm('Refresh network-exposure scores from Wazuh syscollector ports for all assets?')) return;
try {
const r = await api.post('/api/v1/assets/refresh-exposure');
alert(`Exposure refreshed: ${r.data?.exposed || 0} of ${r.data?.assets || 0} assets have exposed listeners.`);
dialog.alert(`Exposure refreshed: ${r.data?.exposed || 0} of ${r.data?.assets || 0} assets have exposed listeners.`);
fetchAssets();
} catch (e: any) {
alert(`Failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
dialog.alert(`Failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
}
}}
className="inline-flex items-center rounded-sm border border-purple-500 text-purple-700 px-3 py-2 text-xs font-mono font-semibold hover:bg-purple-50 whitespace-nowrap"
className="tv-btn"
title="Pull open listeners from Wazuh and recompute exposure scores"
>
Exposure
Exposure
</button>
)}
<button
type="button"
onClick={handleExportCsv}
disabled={exportLoading}
className="inline-flex items-center rounded-sm border border-gray-300 text-gray-700 px-3 py-2 text-xs font-mono font-semibold hover:bg-gray-50 whitespace-nowrap disabled:opacity-50"
className="tv-btn"
title="Export all matching assets as CSV — includes every field, ignores pagination"
>
<ArrowDownTrayIcon className="h-4 w-4 mr-1" />
{exportLoading ? 'Exporting…' : 'Export CSV'}
</button>
{canEdit && (
@@ -603,7 +640,7 @@ export default function AssetsPage() {
</div>
))}
<div className="text-gray-400">
Set by the sync, read-only. The Entra device ID stays the same when a
Set by the sync, read-only what each sync finds this asset by. The Entra device ID stays the same when a
device is deleted in Intune but still reported by Defender TVM under
another name use it to identify or exclude the device there.
</div>
@@ -728,6 +765,25 @@ export default function AssetsPage() {
<Link href={`/vulnerabilities?asset_id=${asset.id}`} className="hover:underline">
{asset.hostname}
</Link>
{!!asset.container_image_count && (
<button
onClick={() => handleShowContainers(asset.id)}
title="Container images Dockhand reports on this Docker host"
className="ml-2 inline-flex items-center rounded-md bg-sky-50 px-1.5 py-0.5 text-xs font-medium text-sky-700 ring-1 ring-inset ring-sky-200 hover:bg-sky-100"
>
{asset.container_image_count} image{asset.container_image_count === 1 ? '' : 's'}
</button>
)}
{!!asset.runs_on?.length && (
<div className="text-xs font-normal text-gray-500">
on {asset.runs_on.map((h, i) => (
<span key={h.id}>
{i > 0 && ', '}
<button onClick={() => handleShowContainers(h.id)} className="text-sky-700 hover:underline">{h.hostname}</button>
</span>
))}
</div>
)}
</td>
<td className="px-3 py-4 text-gray-500">{asset.ip_address}</td>
<td className="px-3 py-4 text-gray-500">
@@ -948,35 +1004,8 @@ export default function AssetsPage() {
</table>
</div>
{/* Pagination */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm font-mono text-gray-600">
<div>
{total === 0 ? '0' : `${(page - 1) * pageSize + 1}${Math.min(page * pageSize, total)}`} of {total}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5">
<span className="text-xs text-gray-500">Per page</span>
<select
value={pageSize}
onChange={(e) => setPageSize(parseInt(e.target.value, 10))}
className="rounded-md border-gray-300 text-sm py-1 pl-2 pr-7 focus:border-truevuln-blue focus:ring-truevuln-blue"
>
{[50, 100, 250, 500, 1000].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</label>
<div className="flex items-center gap-1">
<button onClick={() => setPage(1)} disabled={page <= 1}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">«</button>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50"> Prev</button>
<span className="px-2">Page {page} / {totalPages}</span>
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">Next </button>
<button onClick={() => setPage(totalPages)} disabled={page >= totalPages}
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-50">»</button>
</div>
</div>
</div>
<Pagination page={page} pageSize={pageSize} total={total}
onPageChange={setPage} onPageSizeChange={setPageSize} />
</div>
{/* Coverage-gap modal — installed packages with no finding */}
@@ -1020,6 +1049,44 @@ export default function AssetsPage() {
</div>
</div>
)}
{containersReport && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setContainersReport(null)}>
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="p-4 border-b border-gray-200">
<h3 className="text-lg font-bold font-mono text-gray-900">Container images {containersReport.hostname}</h3>
<p className="text-xs text-gray-500 font-mono mt-1">
{containersReport.images.length} images with findings, via Dockhand (images without findings are not in its export)
</p>
</div>
<div className="overflow-y-auto p-4">
{containersReport.images.length === 0 ? (
<p className="text-sm text-gray-500 font-mono">No container images linked to this host.</p>
) : (
<table className="min-w-full text-xs font-mono">
<thead className="text-gray-500 uppercase">
<tr><th className="text-left py-1">Image</th><th className="text-left py-1">Containers</th><th className="text-right py-1">Open</th></tr>
</thead>
<tbody className="divide-y divide-gray-100">
{containersReport.images.map((i: any) => (
<tr key={i.id}>
<td className="py-1 pr-3">
<Link href={`/vulnerabilities?asset_id=${i.id}`} className="text-truevuln-blue hover:underline">{i.image}</Link>
{i.status !== 'active' && <span className="ml-1 text-amber-600">({i.status})</span>}
</td>
<td className="py-1 pr-3 text-gray-500">{i.containers || '—'}</td>
<td className="py-1 text-right text-gray-900">{i.vulnerability_count}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="p-3 border-t border-gray-200 text-right">
<button onClick={() => setContainersReport(null)} className="rounded-md bg-gray-100 px-4 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-200">Close</button>
</div>
</div>
</div>
)}
{softwareReport && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setSoftwareReport(null)}>
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
+13 -10
View File
@@ -1,9 +1,13 @@
"use client";
import { PageLoader } from "@/components/ui/Loading";
import { dialog } from "@/components/ui/dialog";
import { useEffect, useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import api from '../../lib/api';
import { ArrowPathIcon, CheckBadgeIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import { CheckBadgeIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import { InlineSpinner } from '@/components/ui/Loading';
type AssetSummary = {
asset_id: number;
@@ -175,7 +179,7 @@ function ComplianceInner() {
});
}
}
alert(msg);
dialog.alert(msg);
await fetchAll();
} catch (e: any) {
setErr(formatApiError(e, 'Impact CSV upload failed.'));
@@ -194,12 +198,12 @@ function ComplianceInner() {
};
const refreshAll = async () => {
if (!confirm('Refresh SCA results for every Wazuh-linked asset? This pulls /sca/{agent_id} per agent.')) return;
if (!await dialog.confirm('Refresh SCA results for every Wazuh-linked asset? This pulls /sca/{agent_id} per agent.')) return;
setRefreshing(true);
setErr(null);
try {
const r = await api.post('/api/v1/compliance/refresh');
alert(`Compliance refreshed: ${r.data.assets_synced} assets, ${r.data.policies_synced} policy results.${(r.data.errors || []).length ? ' Some errors — see backend logs.' : ''}`);
dialog.alert(`Compliance refreshed: ${r.data.assets_synced} assets, ${r.data.policies_synced} policy results.${(r.data.errors || []).length ? ' Some errors — see backend logs.' : ''}`);
await fetchAll();
} catch (e: any) {
setErr(formatApiError(e, 'Refresh failed.'));
@@ -241,7 +245,7 @@ function ComplianceInner() {
}
};
if (loading) return <div className="p-8 text-center font-mono text-gray-500">Loading compliance data</div>;
if (loading) return <PageLoader label="Loading compliance data…" />;
return (
<div className="w-full p-4 sm:p-6 lg:p-8">
@@ -260,8 +264,7 @@ function ComplianceInner() {
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-md bg-truevuln-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-50 font-mono"
>
<ArrowPathIcon className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{refreshing ? 'Refreshing…' : 'Refresh All'}
{refreshing ? <><InlineSpinner /> Refreshing</> : 'Refresh All'}
</button>
</div>
@@ -348,7 +351,7 @@ function ComplianceInner() {
onClick={recomputeURS}
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 font-mono"
>
recompute
recompute
</button>
</div>
</div>
@@ -487,7 +490,7 @@ function ComplianceInner() {
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 font-mono"
title="Re-fetch from Wazuh"
>
refresh
refresh
</button>
<button
onClick={() => setSelectedAsset(null)}
@@ -536,7 +539,7 @@ function ComplianceInner() {
export default function CompliancePage() {
return (
<Suspense fallback={<div className="p-8 text-center font-mono text-gray-500">Loading compliance data</div>}>
<Suspense fallback={<PageLoader label="Loading compliance data…" />}>
<ComplianceInner />
</Suspense>
);
+261
View File
@@ -224,6 +224,9 @@ html[data-theme="mid"] .scroll-visible-x::-webkit-scrollbar-thumb, html[data-the
* light-gray under light text) ---- */
html[data-theme="mid"] .bg-gray-50\/50, html[data-theme="dark"] .bg-gray-50\/50 { background-color: var(--tv-surface-2); }
html[data-theme="mid"] .bg-white\/50, html[data-theme="dark"] .bg-white\/50 { background-color: var(--tv-surface-3); }
/* settings disclosure cards: the divider exists only while the card is open,
* so it carries its own variant class and misses the .border-gray-100 remap */
html[data-theme="mid"] .group-open\:border-gray-100, html[data-theme="dark"] .group-open\:border-gray-100 { border-color: var(--tv-border-soft); }
/* vulnerabilities list: sticky header band uses an arbitrary value of the
* light page background (#F3F4F6 at 95%) */
html[data-theme="mid"] .bg-\[\#F3F4F6\]\/95, html[data-theme="dark"] .bg-\[\#F3F4F6\]\/95 { background-color: var(--tv-bg); }
@@ -246,3 +249,261 @@ html[data-theme="mid"] .border-indigo-200, html[data-theme="dark"] .border-indig
/* daisyui base-200 (used by a few shells) */
html[data-theme="mid"] .bg-base-200, html[data-theme="dark"] .bg-base-200 { background-color: var(--tv-surface-2); }
/* ============================================================
* DESIGN TOKENS (OKLCH) + component classes.
*
* The remap layer above retro-fits dark mode onto hardcoded light
* utilities. Everything below is the opposite direction: tokens
* first, components built only from tokens so .tv-* classes
* follow light/mid/dark on their own and need no remap entry.
*
* Palette rule: neutrals carry the UI, brand marks the one primary
* action, and semantic hues are reserved for STATE (danger / warn /
* ok / info). Anything that is merely an identity label (scanner
* name, package, PoC count) stays neutral see DESIGN.md.
* ============================================================ */
:root {
/* brand — the existing TrueVuln blue, expressed in OKLCH */
--tv-brand: oklch(0.52 0.21 258);
--tv-brand-hover: oklch(0.45 0.21 258);
--tv-brand-solid: oklch(0.52 0.21 258);
--tv-brand-soft: oklch(0.96 0.030 258);
--tv-brand-ring: oklch(0.82 0.085 258);
--tv-brand-on: #ffffff;
/* neutrals — chroma ~0, one hue so greys stay in family */
--tv-fg: oklch(0.26 0.015 258);
--tv-fg-soft: oklch(0.43 0.012 258);
--tv-fg-muted: oklch(0.58 0.010 258);
--tv-line: oklch(0.90 0.005 258);
--tv-line-strong: oklch(0.83 0.008 258);
--tv-btn-bg: oklch(1 0 0);
--tv-btn-bg-hover:oklch(0.965 0.004 258);
--tv-mute-fg: oklch(0.45 0.012 258);
--tv-mute-bg: oklch(0.965 0.004 258);
--tv-mute-bd: oklch(0.91 0.005 258);
/* semantic — state only, never decoration */
--tv-danger: oklch(0.50 0.19 27);
--tv-danger-bg: oklch(0.955 0.030 27);
--tv-danger-bd: oklch(0.88 0.070 27);
--tv-danger-solid:oklch(0.55 0.21 27);
--tv-warn: oklch(0.50 0.13 72);
--tv-warn-bg: oklch(0.960 0.045 82);
--tv-warn-bd: oklch(0.88 0.080 82);
--tv-ok: oklch(0.48 0.12 158);
--tv-ok-bg: oklch(0.955 0.040 158);
--tv-ok-bd: oklch(0.87 0.070 158);
--tv-info: oklch(0.50 0.15 250);
--tv-info-bg: oklch(0.955 0.035 250);
--tv-info-bd: oklch(0.87 0.070 250);
}
/* Dark modes: neutrals come from the surface scale defined above;
* semantic fills become translucent tints so they sit ON the surface
* instead of punching a pastel hole in it, and the foregrounds lift
* to keep AA contrast on a dark ground. */
html[data-theme="mid"],
html[data-theme="dark"] {
--tv-brand: oklch(0.66 0.16 258);
--tv-brand-hover: oklch(0.58 0.21 258);
--tv-brand-solid: oklch(0.50 0.21 258);
--tv-brand-soft: color-mix(in oklab, oklch(0.66 0.16 258) 18%, transparent);
--tv-brand-ring: color-mix(in oklab, oklch(0.66 0.16 258) 45%, transparent);
--tv-fg: var(--tv-text);
--tv-fg-soft: var(--tv-text-soft);
--tv-fg-muted: var(--tv-text-muted);
--tv-line: var(--tv-border-soft);
--tv-line-strong: var(--tv-border);
--tv-btn-bg: var(--tv-surface-2);
--tv-btn-bg-hover:var(--tv-surface-3);
--tv-mute-fg: var(--tv-text-soft);
--tv-mute-bg: var(--tv-surface-3);
--tv-mute-bd: var(--tv-border-soft);
--tv-danger: oklch(0.74 0.15 27);
--tv-danger-bg: color-mix(in oklab, oklch(0.65 0.19 27) 20%, transparent);
--tv-danger-bd: color-mix(in oklab, oklch(0.65 0.19 27) 45%, transparent);
--tv-danger-solid:oklch(0.55 0.21 27);
--tv-warn: oklch(0.80 0.13 82);
--tv-warn-bg: color-mix(in oklab, oklch(0.75 0.15 82) 18%, transparent);
--tv-warn-bd: color-mix(in oklab, oklch(0.75 0.15 82) 40%, transparent);
--tv-ok: oklch(0.76 0.14 158);
--tv-ok-bg: color-mix(in oklab, oklch(0.70 0.15 158) 18%, transparent);
--tv-ok-bd: color-mix(in oklab, oklch(0.70 0.15 158) 40%, transparent);
--tv-info: oklch(0.75 0.12 250);
--tv-info-bg: color-mix(in oklab, oklch(0.68 0.15 250) 20%, transparent);
--tv-info-bd: color-mix(in oklab, oklch(0.68 0.15 250) 42%, transparent);
}
@layer components {
/* ---- buttons: neutral by default, brand only for the primary ---- */
.tv-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
border: 1px solid var(--tv-line);
border-top-color: color-mix(in oklab, var(--tv-line) 70%, var(--tv-btn-bg));
border-radius: 0.375rem;
background: var(--tv-btn-bg);
color: var(--tv-fg-soft);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
line-height: 1.1rem;
white-space: nowrap;
transition: background-color .12s, color .12s, border-color .12s;
}
.tv-btn:hover:not(:disabled) {
background: var(--tv-btn-bg-hover);
border-color: var(--tv-line-strong);
color: var(--tv-fg);
}
.tv-btn:disabled { opacity: .5; cursor: not-allowed; }
.tv-btn--primary {
background: var(--tv-brand-solid);
border-color: var(--tv-brand-solid);
color: var(--tv-brand-on);
}
.tv-btn--danger {
background: var(--tv-danger-solid);
border-color: var(--tv-danger-solid);
color: #fff;
}
.tv-btn--danger:hover:not(:disabled) {
background: color-mix(in oklab, var(--tv-danger-solid) 85%, black);
border-color: color-mix(in oklab, var(--tv-danger-solid) 85%, black);
color: #fff;
}
.tv-btn--primary:hover:not(:disabled) {
background: var(--tv-brand-hover);
border-color: var(--tv-brand-hover);
color: var(--tv-brand-on);
}
/* result popover hanging under a job button */
.tv-pop {
position: absolute;
top: 100%;
right: 0;
z-index: 30;
margin-top: 0.25rem;
width: 15rem;
white-space: normal;
border: 1px solid var(--tv-line-strong);
border-radius: 0.375rem;
background: var(--tv-btn-bg);
color: var(--tv-fg-soft);
padding: 0.25rem 0.5rem;
font-size: 10px;
line-height: 1.35;
box-shadow: 0 1px 2px oklch(0 0 0 / .10), 0 6px 16px -6px oklch(0 0 0 / .18);
}
/* ---- filter chips: neutral when off, brand-tinted when on ----
* :has() does the state no JS, no per-filter hue. */
.tv-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.4rem 0.7rem;
border: 1px solid var(--tv-line);
border-radius: 9999px;
background: var(--tv-btn-bg);
color: var(--tv-fg-muted);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
line-height: 1.1rem;
white-space: nowrap;
cursor: pointer;
user-select: none;
transition: background-color .12s, color .12s, border-color .12s;
}
.tv-chip:hover { background: var(--tv-btn-bg-hover); color: var(--tv-fg); }
.tv-chip:has(input:checked) {
background: var(--tv-brand-soft);
border-color: var(--tv-brand-ring);
color: var(--tv-brand);
font-weight: 700;
}
.tv-chip__box {
accent-color: var(--tv-brand);
width: 0.85rem;
height: 0.85rem;
border-radius: 0.2rem;
margin: 0;
}
/* ---- tags: default neutral. A hue means STATE, nothing else. ---- */
.tv-tag {
display: inline-flex;
align-items: center;
border: 1px solid var(--tv-mute-bd);
border-radius: 0.25rem;
background: var(--tv-mute-bg);
color: var(--tv-mute-fg);
padding: 0.125rem 0.375rem;
font-size: 10px;
font-weight: 700;
line-height: 1.35;
white-space: nowrap;
}
.tv-tag--danger { background: var(--tv-danger-bg); border-color: var(--tv-danger-bd); color: var(--tv-danger); }
.tv-tag--warn { background: var(--tv-warn-bg); border-color: var(--tv-warn-bd); color: var(--tv-warn); }
.tv-tag--ok { background: var(--tv-ok-bg); border-color: var(--tv-ok-bd); color: var(--tv-ok); }
.tv-tag--info { background: var(--tv-info-bg); border-color: var(--tv-info-bd); color: var(--tv-info); }
/* solid = the one step above danger: weaponised / widespread */
.tv-tag--alarm { background: var(--tv-danger-solid); border-color: var(--tv-danger-solid); color: #fff; }
/* severity pill sits one size up — it is the column's primary signal */
.tv-tag--lg { padding: 0.2rem 0.5rem; font-size: 0.7rem; letter-spacing: .02em; }
/* ---- score bars ---- */
.tv-bar { background: var(--tv-mute-bg); border-radius: 9999px; overflow: hidden; }
.tv-bar__hi { background: var(--tv-danger-solid); }
.tv-bar__mid { background: oklch(0.68 0.15 60); }
.tv-bar__low { background: oklch(0.62 0.13 158); }
/* ---- collapsible job-action drawer ---- */
.tv-drawer { border-top: 1px solid var(--tv-line); }
.tv-drawer > summary {
display: inline-flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
list-style: none;
color: var(--tv-fg-muted);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: .06em;
padding: 0.25rem 0;
}
.tv-drawer > summary::-webkit-details-marker { display: none; }
.tv-drawer > summary:hover { color: var(--tv-fg); }
.tv-drawer > summary::before {
content: "▸";
transition: transform .15s;
display: inline-block;
}
.tv-drawer[open] > summary::before { transform: rotate(90deg); }
/* ---- collapsible page section: same caret as the drawer, card header ---- */
.tv-section > summary {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.tv-section > summary::-webkit-details-marker { display: none; }
.tv-section > summary::before {
content: "▸";
color: var(--tv-fg-muted);
transition: transform .15s;
display: inline-block;
}
.tv-section[open] > summary::before { transform: rotate(90deg); }
}
+10 -7
View File
@@ -1,14 +1,16 @@
"use client";
import { PageLoader } from "@/components/ui/Loading";
import { dialog } from "@/components/ui/dialog";
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '../../lib/api';
import { Group, UserInfo } from '../../types';
import {
PlusIcon,
TrashIcon,
PencilSquareIcon,
UserGroupIcon,
CheckBadgeIcon,
} from '@heroicons/react/24/outline';
const GroupsPage = () => {
@@ -20,19 +22,22 @@ const GroupsPage = () => {
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [formData, setFormData] = useState({ name: '', description: '', user_ids: [] as number[] });
const [status, setStatus] = useState<{ message: string, type: string }>({ message: '', type: '' });
const router = useRouter();
const fetchData = async () => {
try {
const [gRes, uRes, dRes] = await Promise.all([
api.get('/api/v1/groups'),
api.get('/auth/users').catch(() => ({ data: [] })),
api.get('/api/v1/groups/default').catch(() => ({ data: null }))
api.get('/api/v1/groups/default')
]);
setGroups(gRes.data);
setUsers(uRes.data);
setDefaultGroupId(dRes.data);
} catch (error) {
console.error("Failed to fetch data", error);
// Groups is admin-only; the sidebar hides it, the URL does not.
if ((error as { response?: { status?: number } })?.response?.status === 403) router.push('/');
} finally {
setLoading(false);
}
@@ -77,7 +82,7 @@ const GroupsPage = () => {
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this group?')) return;
if (!await dialog.confirm('Are you sure you want to delete this group?', { tone: "danger", confirmLabel: "Delete group" })) return;
try {
await api.delete(`/api/v1/groups/${id}`);
fetchData();
@@ -109,7 +114,7 @@ const GroupsPage = () => {
}));
};
if (loading) return <div className="p-8 text-center font-mono">Loading Groups...</div>;
if (loading) return <PageLoader label="Loading groups…" />;
return (
<div className="max-w-7xl mx-auto p-6">
@@ -125,7 +130,6 @@ const GroupsPage = () => {
onClick={() => handleOpenModal()}
className="inline-flex items-center rounded-md bg-truevuln-blue px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 font-mono"
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Group
</button>
</div>
@@ -173,7 +177,6 @@ const GroupsPage = () => {
onClick={() => handleSetDefault(group.id)}
className="mt-4 w-full flex justify-center items-center gap-2 px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded border border-blue-200"
>
<CheckBadgeIcon className="h-4 w-4" />
Set as Default
</button>
) : (
+4
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; // Keep fonts if desired, or replace with Inter as per design request
import "./globals.css";
import AppShell from "@/components/shared/shell/AppShell";
import { DialogHost } from "@/components/ui/dialog";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -43,6 +44,9 @@ export default function RootLayout({
<AppShell>
{children}
</AppShell>
{/* One host for every alert/confirm/prompt in the app see
components/ui/dialog.tsx */}
<DialogHost />
</body>
</html>
);
+15 -5
View File
@@ -5,11 +5,11 @@ import {
EnvelopeIcon,
CheckCircleIcon,
ExclamationCircleIcon,
EyeIcon,
XMarkIcon,
ClockIcon
} from '@heroicons/react/24/outline';
import api from '../../lib/api';
import Pagination from '../../components/shared/Pagination';
import { format } from 'date-fns';
interface NotificationLog {
@@ -30,12 +30,20 @@ export default function NotificationsPage() {
const [logs, setLogs] = useState<NotificationLog[]>([]);
const [loading, setLoading] = useState(true);
const [previewLog, setPreviewLog] = useState<NotificationLog | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(100);
const [total, setTotal] = useState(0);
useEffect(() => {
const fetchLogs = async () => {
setLoading(true);
try {
const response = await api.get('/api/v1/notifications/log');
const response = await api.get('/api/v1/notifications/log', {
params: { limit: pageSize, offset: (page - 1) * pageSize },
});
setLogs(response.data);
const t = parseInt(response.headers['x-total-count'] ?? '0', 10);
setTotal(Number.isNaN(t) ? response.data.length : t);
} catch (error) {
console.error("Failed to fetch notification logs", error);
} finally {
@@ -43,7 +51,7 @@ export default function NotificationsPage() {
}
};
fetchLogs();
}, []);
}, [page, pageSize]);
const getStatusIcon = (status: string) => {
switch (status.toLowerCase()) {
@@ -65,7 +73,7 @@ export default function NotificationsPage() {
</div>
<div className="bg-white p-2 rounded-lg shadow-sm border border-gray-100 flex items-center gap-2">
<EnvelopeIcon className="h-5 w-5 text-truevuln-blue" />
<span className="text-sm font-bold text-gray-700">{logs.length} Notifications</span>
<span className="text-sm font-bold text-gray-700">{total.toLocaleString()} Notifications</span>
</div>
</div>
@@ -115,7 +123,6 @@ export default function NotificationsPage() {
onClick={() => setPreviewLog(log)}
className="text-truevuln-blue hover:text-blue-900 flex items-center gap-1 ml-auto"
>
<EyeIcon className="h-4 w-4" />
Preview
</button>
</td>
@@ -130,6 +137,9 @@ export default function NotificationsPage() {
)}
</tbody>
</table>
<Pagination page={page} pageSize={pageSize} total={total}
onPageChange={setPage}
onPageSizeChange={(n) => { setPageSize(n); setPage(1); }} />
</div>
)}
+46 -10
View File
@@ -1,15 +1,16 @@
"use client";
import { PageLoader } from "@/components/ui/Loading";
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
ArrowUpRightIcon,
ArrowDownRightIcon,
SparklesIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import api from '../lib/api';
import { SyncHealthBanner } from '../components/shared/SyncHealth';
import { kevBadge } from '../lib/kevBadge';
import { DashboardStats, Vulnerability, AIPriorityResponse } from '../types';
import AIRecommendations from '@/components/AIRecommendations';
@@ -31,6 +32,11 @@ function renderVulnWidget(opts: {
// EOL widget: replace the (often empty) CPR column with the affected
// asset's hostname, linked to that asset's EOL findings.
assetColumn?: boolean;
// The page renders as soon as the stat cards have their numbers, so a
// widget can be on screen before its own request has answered. Empty then
// means "not back yet", and it must NOT read as "No data." — that is the
// one confusion this dashboard cannot afford.
loading?: boolean;
}) {
const { title, subtitle, vulns, onRowClick, viewAllHref } = opts;
const labelField = opts.labelField || 'cve_id';
@@ -129,7 +135,14 @@ function renderVulnWidget(opts: {
</tr>
))}
{vulns.length === 0 && (
<tr><td colSpan={6} className="text-center py-4 text-gray-500">No data.</td></tr>
<tr><td colSpan={6} className="text-center py-4 text-gray-500">
{opts.loading
? <span className="inline-flex items-center gap-2 text-gray-400">
<span className="h-3 w-3 rounded-full border-2 border-gray-300 border-t-truevuln-blue animate-spin" />
Loading
</span>
: 'No data.'}
</td></tr>
)}
</tbody>
</table>
@@ -144,6 +157,9 @@ export default function Dashboard() {
const [recentVulns, setRecentVulns] = useState<Vulnerability[]>([]);
const [aiPriorities, setAiPriorities] = useState<AIPriorityResponse | null>(null);
const [loading, setLoading] = useState(true);
// Separate from `loading`: the page unblocks on the stat cards, and the
// widgets keep their own spinner until the rest of the batch is in.
const [widgetsLoading, setWidgetsLoading] = useState(true);
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
// AI audit hits an editor-gated endpoint → hide the controls from read-only.
@@ -253,8 +269,16 @@ export default function Dashboard() {
useEffect(() => {
const fetchData = async () => {
try {
// The stat cards need this one and nothing else, so it lifts the
// page's own gate as soon as it lands. Before, the whole dashboard sat
// behind `Promise.all` of thirteen requests and showed a bare
// "Loading Dashboard..." until the SLOWEST of them answered — the
// numbers were ready long before the page admitted it.
const statsP = api.get('/api/v1/vulnerabilities/reports/dashboard');
statsP.then((r) => { setStats(r.data); setLoading(false); }).catch(() => { });
const [statsRes, vulnsRes, criticalRes, eolRes, mobileRes, kevRes, kevMineRes, schedRes, compRes, ursRes] = await Promise.all([
api.get('/api/v1/vulnerabilities/reports/dashboard'),
statsP,
// Newly Published: sort by published_date desc. distinct_cve=true
// collapses per-asset duplicates server-side so we reliably get 10
// distinct CVEs (client dedup alone starved when a CVE hit N assets).
@@ -417,6 +441,7 @@ export default function Dashboard() {
console.error("Failed to fetch dashboard data:", error);
} finally {
setLoading(false);
setWidgetsLoading(false);
}
};
@@ -424,7 +449,9 @@ export default function Dashboard() {
}, []);
if (loading) {
return <div className="p-8 text-center">Loading Dashboard...</div>;
return (
<PageLoader label="Loading dashboard…" />
);
}
const statItems = [
@@ -437,6 +464,7 @@ export default function Dashboard() {
// Full available width (minus AppShell padding) — the empty left/right
// gutters should be used on wide monitors. No max-width cap.
<div className="w-full">
<SyncHealthBanner />
{/* Header Section */}
<div className="md:flex md:items-center md:justify-between mb-8">
<div className="min-w-0 flex-1">
@@ -467,7 +495,6 @@ export default function Dashboard() {
onClick={cancelAIAudit}
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-red-300 bg-red-50 text-red-700 hover:bg-red-100 transition-all"
>
<svg className="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
Cancel AI Audit
</button>
) : (
@@ -475,7 +502,6 @@ export default function Dashboard() {
onClick={() => fetchAIRecommendations()}
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-indigo-50 hover:ring-indigo-300 transition-all"
>
<SparklesIcon className="h-4 w-4 mr-2 text-indigo-600" />
AI Audit
</button>
)}
@@ -822,6 +848,7 @@ export default function Dashboard() {
title: 'Recent Critical CVEs',
subtitle: 'CVSS ≥ 8 or Critical or KEV or EUVD · newest first, ties by CPR',
vulns: criticalVulns,
loading: widgetsLoading,
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
})}
@@ -829,6 +856,7 @@ export default function Dashboard() {
title: 'Newly Published CVEs',
subtitle: 'All severities · sorted by CVE published date',
vulns: recentVulns,
loading: widgetsLoading,
onRowClick: (cveId) => router.push(`/vulnerabilities?cve_id=${cveId}`),
viewAllHref: '/vulnerabilities?sort_by=published_date&sort_order=desc',
})}
@@ -836,6 +864,7 @@ export default function Dashboard() {
title: 'Newly EOL / EOS',
subtitle: 'End-of-life software · sorted by detection',
vulns: eolVulns,
loading: widgetsLoading,
labelField: 'package_name',
firstColHeader: 'Product',
assetColumn: true,
@@ -847,6 +876,7 @@ export default function Dashboard() {
title: 'Mobile Security · EOL & Patch Level',
subtitle: 'Phones & tablets · vendor EOL/EOS + Android patch staleness',
vulns: mobileVulns,
loading: widgetsLoading,
labelField: 'package_name',
firstColHeader: 'Device / Item',
assetColumn: true,
@@ -865,15 +895,16 @@ export default function Dashboard() {
wherever its data is shown so the sources come from the
same response as the rows. */}
<p className="text-[11px] text-gray-500 font-mono mt-0.5">
{(kevSourceLabels.length ? kevSourceLabels : ['CISA KEV', 'ENISA EUVD']).join(' · ')} · 🔒 = Ransomware use
{(kevSourceLabels.length ? kevSourceLabels : ['CISA KEV', 'ENISA EUVD']).join(' · ')} · RANSOM = known ransomware use
</p>
</div>
<a href="/advisories"
<a href="/advisories/kev"
className="text-truevuln-blue text-[10px] font-bold uppercase tracking-wider font-mono hover:text-blue-700">View All &gt;</a>
</div>
<div className="overflow-x-auto flex-1">
{kevAdvisories.length === 0 ? (
<p className="p-4 text-xs text-gray-400 font-mono">No KEV data.</p>
<p className="p-4 text-xs text-gray-400 font-mono">
{widgetsLoading ? 'Loading…' : 'No KEV data.'}</p>
) : (
<table className="min-w-full text-sm">
<tbody className="divide-y divide-gray-100">
@@ -882,7 +913,12 @@ export default function Dashboard() {
onClick={() => router.push(`/vulnerabilities?cve_id=${k.cve_id}`)}>
<td className="px-3 py-2 whitespace-nowrap">
<span className="font-mono text-truevuln-blue text-xs font-bold">{k.cve_id}</span>
{k.ransomware && <span title="Known Ransomware campaign use" className="ml-1">🔒</span>}
{k.ransomware && (
<span title="Known Ransomware campaign use"
className="ml-1 rounded px-1 py-0.5 text-[9px] font-bold font-mono bg-red-50 text-red-700">
RANSOM
</span>
)}
</td>
<td className="px-3 py-2 text-xs text-gray-600 truncate max-w-[180px]" title={`${k.vendor || ''} ${k.product || ''}`}>
{[k.vendor, k.product].filter(Boolean).join(' · ') || k.name || ''}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { PageLoader } from "@/components/ui/Loading";
// Patch Plan — the findings list grouped into fix actions: "update X" with
// what it removes. Ranked by the backend (patch_plan_service): exploited in
// the wild (KEV / EUVD) first, then summed priority score. The Jev column is
// a hint to check the setup, never part of the rank.
import { useEffect, useState } from 'react';
import api from '../../lib/api';
import Pagination from '../../components/shared/Pagination';
type Group = {
product: string;
hosts: number;
findings: number;
cve_count: number;
fixed_versions: string[];
exploited_cves: string[];
config_dependent_cves: string[];
priority_sum: number;
priority_max: number;
rank: number;
};
// Sort value per column; list columns sort by their length.
const sortValue = (g: Group, col: string): string | number => {
if (col === 'exploited') return g.exploited_cves.length;
if (col === 'config') return g.config_dependent_cves.length;
if (col === 'fixed') return g.fixed_versions.join(', ');
return g[col as 'product' | 'hosts' | 'findings' | 'cve_count' | 'priority_sum' | 'rank'];
};
export default function PatchPlanPage() {
const [plan, setPlan] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Default = the backend's rank (exploited first, then summed priority).
const [sortBy, setSortBy] = useState('rank');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
// The backend returns the whole plan; paging and sorting are local.
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(100);
const [exportLoading, setExportLoading] = useState(false);
useEffect(() => {
api.get('/api/v1/vulnerabilities/patch-plan')
.then((r) => setPlan((r.data || []).map((g: Group, i: number) => ({ ...g, rank: i + 1 }))))
// detail is an object list on a 422 (an older backend routes
// /patch-plan to /{vuln_id}) — rendering it would blank the page.
.catch((e) => {
const d = e?.response?.data?.detail;
setError(typeof d === 'string' ? d
: `Could not load the patch plan (HTTP ${e?.response?.status ?? '?'}). Is the backend up to date?`);
})
.finally(() => setLoading(false));
}, []);
// Whole plan, not the page — same as the assets export.
const handleExportCsv = async () => {
setError('');
setExportLoading(true);
try {
const response = await api.get('/api/v1/vulnerabilities/patch-plan/export', { responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
const disposition = response.headers['content-disposition'] as string | undefined;
link.setAttribute('download', disposition?.match(/filename="?([^";]+)"?/)?.[1] || 'patch_plan.csv');
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
setError('Could not export the patch plan as CSV.');
} finally {
setExportLoading(false);
}
};
const handleSort = (column: string) => {
setPage(1);
if (sortBy === column) {
setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'));
} else {
setSortBy(column);
setSortOrder('asc');
}
};
const sortArrow = (column: string) => {
if (sortBy !== column) {
return <span className="text-gray-300 ml-1"></span>;
}
return (
<span className="text-truevuln-blue ml-1">
{sortOrder === 'desc' ? '↓' : '↑'}
</span>
);
};
const sorted = [...plan].sort((a, b) => {
const x = sortValue(a, sortBy), y = sortValue(b, sortBy);
const c = typeof x === 'number' && typeof y === 'number'
? x - y : String(x).localeCompare(String(y), undefined, { numeric: true });
return sortOrder === 'asc' ? c : -c;
});
const shown = sorted.slice((page - 1) * pageSize, page * pageSize);
if (loading) return <PageLoader label="Loading patch plan…" />;
return (
<div className="p-8">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-3xl font-bold text-gray-900 font-mono">Patch Plan</h2>
<p className="mt-1 mb-6 text-sm text-gray-500">
Open findings on active assets, grouped by the product to update. Exploited in the wild
(CISA KEV / ENISA EUVD) first, then by the summed priority score the update removes.
</p>
</div>
<button
type="button"
onClick={handleExportCsv}
disabled={exportLoading || plan.length === 0}
className="tv-btn whitespace-nowrap"
title="Export the whole plan as CSV — ignores pagination"
>
{exportLoading ? 'Exporting…' : 'Export CSV'}
</button>
</div>
{error && <p className="text-sm text-red-600 font-mono mb-4">{error}</p>}
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50 text-xs font-mono uppercase text-gray-500">
<tr>
<th onClick={() => handleSort('rank')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700" title="Recommended order: exploited first, then summed priority">#{sortArrow('rank')}</th>
<th onClick={() => handleSort('product')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700">Update{sortArrow('product')}</th>
<th onClick={() => handleSort('fixed')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700">Fixed in{sortArrow('fixed')}</th>
<th onClick={() => handleSort('hosts')} className="px-3 py-2 text-right cursor-pointer select-none hover:text-gray-700">Hosts{sortArrow('hosts')}</th>
<th onClick={() => handleSort('findings')} className="px-3 py-2 text-right cursor-pointer select-none hover:text-gray-700">Findings{sortArrow('findings')}</th>
<th onClick={() => handleSort('cve_count')} className="px-3 py-2 text-right cursor-pointer select-none hover:text-gray-700">CVEs{sortArrow('cve_count')}</th>
<th onClick={() => handleSort('exploited')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700">Exploited{sortArrow('exploited')}</th>
<th onClick={() => handleSort('priority_sum')} className="px-3 py-2 text-right cursor-pointer select-none hover:text-gray-700" title="Summed / highest priority score">Priority Σ / max{sortArrow('priority_sum')}</th>
<th onClick={() => handleSort('config')} className="px-3 py-2 text-left cursor-pointer select-none hover:text-gray-700" title="Jev: CVE text suggests it needs an optional feature or non-default configuration. Check your setup; the rank is unaffected.">Check config{sortArrow('config')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 font-mono">
{plan.length === 0 && !error && (
<tr><td colSpan={9} className="px-3 py-4 text-gray-400">No open findings on active assets.</td></tr>
)}
{shown.map((g) => (
<tr key={g.product} className={g.exploited_cves.length ? 'bg-red-50/40' : ''}>
<td className="px-3 py-2 text-gray-400">{g.rank}</td>
<td className="px-3 py-2">
<a href={`/vulnerabilities?search=${encodeURIComponent(`"${g.product}"`)}`}
className="font-semibold text-truevuln-blue hover:underline">{g.product}</a>
</td>
<td className="px-3 py-2 text-xs text-gray-600" title={g.fixed_versions.join(', ')}>
{g.fixed_versions.slice(0, 3).join(', ') || '—'}
{g.fixed_versions.length > 3 && ` +${g.fixed_versions.length - 3}`}
</td>
<td className="px-3 py-2 text-right">{g.hosts}</td>
<td className="px-3 py-2 text-right">{g.findings}</td>
<td className="px-3 py-2 text-right">{g.cve_count}</td>
<td className="px-3 py-2 text-xs">
{g.exploited_cves.length > 0 && (
<span className="rounded px-1.5 py-0.5 bg-red-100 text-red-700 font-bold"
title={g.exploited_cves.join(', ')}>
{g.exploited_cves.length} KEV/EUVD
</span>
)}
</td>
<td className="px-3 py-2 text-right">
{g.priority_sum} <span className="text-gray-400">/ {g.priority_max}</span>
</td>
<td className="px-3 py-2 text-xs">
{g.config_dependent_cves.length > 0 && (
<span className="rounded px-1.5 py-0.5 bg-amber-100 text-amber-800"
title={g.config_dependent_cves.join(', ')}>
{g.config_dependent_cves.length} CVE
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination page={page} pageSize={pageSize} total={plan.length}
onPageChange={setPage} onPageSizeChange={(n) => { setPageSize(n); setPage(1); }} />
</div>
</div>
);
}
+10 -4
View File
@@ -1,10 +1,12 @@
"use client";
import { dialog } from "@/components/ui/dialog";
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
CheckCircleIcon,
ExclamationTriangleIcon,
PlusIcon,
ShieldCheckIcon,
ClockIcon,
PencilIcon,
@@ -43,6 +45,7 @@ export default function PoliciesPage() {
const [editingPolicy, setEditingPolicy] = useState<Partial<Policy>>(DEFAULT_POLICY);
const [isEditing, setIsEditing] = useState(false);
const [userRole, setUserRole] = useState('');
const router = useRouter();
// Create + Delete are RequireAdmin server-side, Edit is RequireEditor.
// The nav lets editors onto this page, so without these two gates an
@@ -59,6 +62,8 @@ export default function PoliciesPage() {
})));
} catch (error) {
console.error("Failed to fetch policies", error);
// Policies is editor+; the sidebar hides it, the URL does not.
if ((error as { response?: { status?: number } })?.response?.status === 403) router.push('/');
} finally {
setLoading(false);
}
@@ -93,12 +98,12 @@ export default function PoliciesPage() {
setIsModalOpen(false);
} catch (error) {
console.error("Failed to save policy", error);
alert("Failed to save policy");
dialog.alert("Failed to save policy");
}
};
const handleDelete = async (id: number) => {
if (!confirm("Are you sure you want to delete this policy? Assets assigned to it will fallback to defaults.")) return;
if (!await dialog.confirm("Are you sure you want to delete this policy? Assets assigned to it will fallback to defaults.", { tone: "danger", confirmLabel: "Delete policy" })) return;
try {
await api.delete(`/api/v1/policies/${id}`);
fetchPolicies();
@@ -119,7 +124,6 @@ export default function PoliciesPage() {
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 bg-truevuln-blue text-white rounded-lg font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20"
>
<PlusIcon className="h-5 w-5" />
Create Policy
</button>
)}
@@ -197,6 +201,7 @@ export default function PoliciesPage() {
<div className="text-2xl font-black text-gray-900">{policy.compliance}%</div>
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Compliance</div>
</div>
{(canEdit || isAdmin) && (
<div className="flex gap-2">
{canEdit && (
<button
@@ -217,6 +222,7 @@ export default function PoliciesPage() {
</button>
)}
</div>
)}
</div>
</div>
</div>
+8 -7
View File
@@ -1,6 +1,8 @@
"use client";
import { DocumentTextIcon, ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { dialog } from "@/components/ui/dialog";
import { DocumentTextIcon } from '@heroicons/react/24/outline';
import api from '../../lib/api';
const reports = [
@@ -48,7 +50,7 @@ export default function ReportsPage() {
link.remove();
} catch (e) {
console.error(e);
alert("Failed to download report. Please ensure you are logged in.");
dialog.alert("Failed to download report. Please ensure you are logged in.");
}
} else if (report.id === 1) {
try {
@@ -62,7 +64,7 @@ export default function ReportsPage() {
link.remove();
} catch (e) {
console.error(e);
alert("Failed to generate PDF report.");
dialog.alert("Failed to generate PDF report.");
}
} else if (report.id === 3) {
try {
@@ -76,7 +78,7 @@ export default function ReportsPage() {
link.remove();
} catch (e) {
console.error(e);
alert("Failed to generate Compliance report.");
dialog.alert("Failed to generate Compliance report.");
}
} else if (report.id === 4) {
try {
@@ -90,10 +92,10 @@ export default function ReportsPage() {
link.remove();
} catch (e) {
console.error(e);
alert("Failed to generate Patching Progress report.");
dialog.alert("Failed to generate Patching Progress report.");
}
} else {
alert(`Report generation for ${report.name} is coming soon!`);
dialog.alert(`Report generation for ${report.name} is coming soon!`);
}
};
@@ -127,7 +129,6 @@ export default function ReportsPage() {
onClick={() => handleDownload(report)}
className="flex items-center gap-2 text-sm font-semibold text-truevuln-blue hover:text-blue-700"
>
<ArrowDownTrayIcon className="h-4 w-4" />
Download
</button>
</div>

Some files were not shown because too many files have changed in this diff Show More